Printer assignment end to end, and a container that keeps a site's files
Assign printers to a MACHINE and let whichever PC controls it install them, with the observed half kept strictly apart from the assigned half: a collector report can never write an assignment row. Adoption is explicit. Adds a singular relationship type so defaultprinter can mean exactly one, client scripts that converge a bay's queues and correct a drifted one in place, and a wave gate so a fleet-wide driver rollout cannot stampede its own share. On the container side: the compose stack never persisted the instance directory, so a rebuild discarded plugins.json and every upload. It is a volume now, the image ships instance/ owned by the app user so the mountpoint is writable, and the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended since 8.0 went end of life. The image also could not build at all: the frontend stage never copied the plugin staging script it runs. Adds a topology migration guide covering IIS to Docker and back.
This commit is contained in:
168
frontend/src/components/PrinterAssignmentPicker.vue
Normal file
168
frontend/src/components/PrinterAssignmentPicker.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<template v-if="enabled">
|
||||
<h4 class="printer-heading">Printers</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label :for="`printersearch-${uid}`">Assigned printers</label>
|
||||
<input
|
||||
:id="`printersearch-${uid}`"
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Filter printers..."
|
||||
/>
|
||||
<div class="printer-list">
|
||||
<label v-for="option in filtered" :key="option.assetid" class="printer-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="assigned.includes(option.assetid)"
|
||||
@change="toggle(option.assetid, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ label(option) }}</span>
|
||||
<span v-if="option.printer?.modelname" class="printer-meta">
|
||||
{{ option.printer.modelname }}
|
||||
</span>
|
||||
</label>
|
||||
<span v-if="!filtered.length" class="muted">No printers match.</span>
|
||||
</div>
|
||||
<small class="form-hint">{{ hint }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label :for="`defaultprinter-${uid}`">Default printer</label>
|
||||
<select :id="`defaultprinter-${uid}`" v-model="defaultAssetId" class="form-control">
|
||||
<option :value="null">No default</option>
|
||||
<!-- Only what is assigned: a default the bay was never told to install
|
||||
fails to apply, and nothing in ShopDB shows why. -->
|
||||
<option v-for="option in assignedOptions" :key="option.assetid" :value="option.assetid">
|
||||
{{ label(option) }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">
|
||||
Optional. Applied per user at logon, because a default printer is a
|
||||
per-user setting that SYSTEM cannot set for somebody else.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// One picker, used by the machine form and the PC form.
|
||||
//
|
||||
// The assignment belongs to the MACHINE - that is what makes a reimaged PC come
|
||||
// back with the bay's printers - and the PC form writes the same shape as an
|
||||
// override. Two copies of this UI would drift, and the two ends of an override
|
||||
// disagreeing is exactly the bug nobody would spot.
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
// The asset being edited. Null while creating: save(assetid) is called with
|
||||
// the new id once it exists.
|
||||
assetid: { type: Number, default: null },
|
||||
// Wording only. What it means to assign here differs: a machine's set is the
|
||||
// bay's, a PC's set overrides the machine it controls.
|
||||
scope: { type: String, default: 'machine' }
|
||||
})
|
||||
|
||||
const uid = Math.random().toString(36).slice(2, 8)
|
||||
const enabled = ref(false)
|
||||
const printers = ref([])
|
||||
const assigned = ref([])
|
||||
const defaultAssetId = ref(null)
|
||||
const search = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return printers.value
|
||||
return printers.value.filter(option => label(option).toLowerCase().includes(term))
|
||||
})
|
||||
|
||||
const assignedOptions = computed(() =>
|
||||
printers.value.filter(option => assigned.value.includes(option.assetid)))
|
||||
|
||||
const hint = computed(() => {
|
||||
const count = assigned.value.length
|
||||
if (props.scope === 'pc') {
|
||||
return `${count} assigned. Printers ticked here belong to this PC and REPLACE `
|
||||
+ 'whatever the machine it controls is assigned - the whole set, not added to it.'
|
||||
}
|
||||
return `${count} assigned. These belong to the machine, so whichever PC controls `
|
||||
+ 'it installs them - including a replacement PC after a reimage.'
|
||||
})
|
||||
|
||||
function label(option) {
|
||||
// A real fleet has printers whose name is the literal string 'NONE' - an
|
||||
// import artefact - and showing that as the label makes two different
|
||||
// printers indistinguishable in the list.
|
||||
const name = option.name && option.name.toUpperCase() !== 'NONE' ? option.name : ''
|
||||
return name || option.assetnumber || `Printer ${option.assetid}`
|
||||
}
|
||||
|
||||
function toggle(assetid, checked) {
|
||||
if (checked) {
|
||||
if (!assigned.value.includes(assetid)) assigned.value.push(assetid)
|
||||
} else {
|
||||
assigned.value = assigned.value.filter(id => id !== assetid)
|
||||
// Unassigning the default clears it rather than leaving a row pointing at a
|
||||
// printer the bay is no longer told to install.
|
||||
if (defaultAssetId.value === assetid) defaultAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
// listAll: perpage is clamped server-side, and a picker that stops at 100
|
||||
// silently hides printers sorting late in the alphabet.
|
||||
printers.value = await printersApi.listAll()
|
||||
enabled.value = true
|
||||
} catch (error) {
|
||||
// A site without the printers plugin has no section at all.
|
||||
enabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssignment(assetid) {
|
||||
if (!enabled.value || !assetid) return
|
||||
try {
|
||||
const response = await printersApi.assignment.get(assetid)
|
||||
const data = response.data.data || {}
|
||||
assigned.value = data.printerassetids || []
|
||||
defaultAssetId.value = data.defaultprinterassetid ?? null
|
||||
} catch (error) {
|
||||
assigned.value = []
|
||||
defaultAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Called by the parent AFTER the asset exists, so a new record can be assigned
|
||||
// in the same save.
|
||||
async function save(assetid) {
|
||||
if (!enabled.value || !assetid) return
|
||||
await printersApi.assignment.set(assetid, assigned.value, defaultAssetId.value)
|
||||
}
|
||||
|
||||
defineExpose({ save })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOptions()
|
||||
await loadAssignment(props.assetid)
|
||||
})
|
||||
|
||||
watch(() => props.assetid, assetid => loadAssignment(assetid))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.printer-heading { margin-top: 1.5rem; margin-bottom: 1rem; }
|
||||
.printer-list {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.printer-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; }
|
||||
.printer-meta { color: var(--text-light); font-size: 0.85em; }
|
||||
.muted { color: var(--text-light); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user