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:
2026-08-20 07:46:35 -04:00
parent 09e9d5f047
commit d267813999
45 changed files with 6475 additions and 56 deletions

View File

@@ -39,6 +39,12 @@ export default [
meta: { requiresAuth: true, plugin: 'computers' }
},
// Computer-specific settings
{
path: 'settings/collector',
name: 'collector-settings',
component: () => import('./views/CollectorSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/pctypes',
name: 'pctypes',

View File

@@ -0,0 +1,134 @@
<template>
<div>
<div class="page-header">
<h2>Asset reporter</h2>
</div>
<p class="setting-description">
Shop-floor PCs report what they are to this server. The script below is
generated with THIS site's values, so it downloads ready to deploy - there
is nothing in it to find and edit.
</p>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label>Routable ranges</label>
<input v-model="ranges" type="text" class="form-control"
placeholder="10.20.0.0/23,10.21.4.0/26" />
<p class="field-hint">
Comma-separated CIDRs for this site's corporate network. A bay with two
NICs - a private controller NIC and a routable one - reports the
address in these ranges. Leave it empty and the PC reports whichever
NIC carries the default route, which is correct at most sites and needs
no configuration.
</p>
</div>
<button class="btn btn-primary" @click="save" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
<div class="card form-card">
<h3>Download the reporter</h3>
<p class="field-hint">
Stamped with this server's URL and the ranges above, and with the version
that generated it, so a script found on a bay can be traced back here.
Re-download after upgrading ShopDB.
</p>
<button class="btn btn-secondary" @click="download" :disabled="downloading">
{{ downloading ? 'Generating...' : 'Download Report-AssetToShopDB.ps1' }}
</button>
<p v-if="digest" class="field-hint mono">
SHA-256 {{ digest }}
</p>
<p class="field-hint">
<strong>The collector key is not in this file, deliberately.</strong> It
lands on every shop-floor PC, and a token spread across hundreds of bays
cannot be rotated quietly. Mint a token scoped to
<code>collector.ingest</code> and provision it as
<code>HKLM:\SOFTWARE\GE\ShopDB</code> value <code>CollectorKey</code> -
the adoption guide has worked examples for Intune, DSC and GE-Enforce.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import api from '@/api'
const RANGES_KEY = 'computers_routableranges'
const ranges = ref('')
const saving = ref(false)
const downloading = ref(false)
const digest = ref('')
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'computers' })
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
if (row) ranges.value = row.value || ''
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
await settingsApi.update(RANGES_KEY, String(ranges.value ?? ''))
message.value = 'Saved. Re-download the script so it carries the new ranges.'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
async function download() {
downloading.value = true
error.value = ''
try {
// responseType text: this is a script, not JSON, and the hash the server
// publishes is of exactly these bytes.
const response = await api.get('/computers/client-script', { responseType: 'text' })
digest.value = response.headers['x-script-sha256'] || ''
const blob = new Blob([response.data], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'Report-AssetToShopDB.ps1'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (downloadError) {
error.value = downloadError.response?.status === 403
? 'Only an administrator can download the reporter'
: 'Could not generate the script'
console.error(downloadError)
} finally {
downloading.value = false
}
}
</script>
<style scoped>
.mono { font-family: monospace; word-break: break-all; }
.form-card h3 { margin-top: 0; }
</style>

View File

@@ -245,6 +245,12 @@
</div>
</div>
<!-- Printers assigned to this PC ITSELF, which replace whatever the
machine it controls is assigned. Same component the machine form
uses: two copies of this UI would drift, and the two ends of an
override disagreeing is the bug nobody would spot. -->
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="pc" />
<div class="form-group">
<label for="notes">Notes</label>
<textarea
@@ -317,16 +323,21 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
printersApi, relationshipTypesApi } from '@/api'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
@@ -338,6 +349,7 @@ const isEdit = computed(() => !!route.params.id)
// values are keyed by the underlying asset id, captured on load / create.
const COMPUTER_ASSETTYPEID = 2
const customFieldsRef = ref(null)
const printerPickerRef = ref(null)
const currentAssetId = ref(null)
// PC Number (assetnumber) defaults to the serial number while the user hasn't
@@ -412,6 +424,7 @@ const models = ref([])
const locations = ref([])
const operatingsystems = ref([])
// Default PC Number to serial while the user hasn't typed their own (new PC only)
watch(() => form.value.serialnumber, (serial) => {
if (!isEdit.value && !manualPcNumber.value && serial) {
@@ -438,7 +451,8 @@ onMounted(async () => {
modelsApi.listAll(), // backend caps perpage at 100; page through all
locationsApi.list({ perpage: 100 }),
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list()
computersApi.protocols.list(),
// Handles its own failure: a site without printers still gets a form.
])
pcTypes.value = ptRes.data.data || []
@@ -570,6 +584,22 @@ async function savePC() {
}
}
// Printer assignment through the shared picker, which reconciles the whole
// set in one call rather than row at a time.
//
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
// whose Save would create a second PC is the worse failure - but an
// assignment that quietly did not happen is the bug this feature exists to
// stop, so it has to be said out loud.
if (assetId && printerPickerRef.value) {
try {
await printerPickerRef.value.save(assetId)
} catch (printerError) {
console.error('Error saving printer assignment:', printerError)
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
}
}
router.push('/pcs')
} catch (err) {
console.error('Error saving PC:', err)
@@ -603,6 +633,30 @@ async function savePC() {
color: var(--text-light);
}
/* Scrolls rather than pushing the rest of the form off screen: a site can hold
dozens of printers. */
.printer-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
gap: 8px;
}
.printer-meta {
color: var(--text-light);
font-size: 0.85rem;
}
.map-location-control {
display: flex;
align-items: center;