Generate the collector script per site, and bring EventSaver into the repo
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

A site adopting ShopDB had to be handed two files and told what to edit in them.
Both are now the product's, and one of them the server writes for you.

GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with
this site's values already in it: site_base_url becomes the -ApiUrl default and
the new computers_routableranges setting becomes -AllowedRanges. Only the
PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays
runnable, so there is no second version to drift from the first - and everything
stamped stays overridable by argument or registry, because a bay may need to
differ from its site. Settings > Computers > Asset reporter edits the ranges,
downloads the script and shows its SHA-256.

The collector key is deliberately not stamped in, and a test fails if it ever
is. That file lands on every shop-floor PC, and a token spread across hundreds
of bays cannot be rotated quietly; it stays in the registry, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.

The routable ranges are the last thing that was hardcoded in that script. They
are now a setting, so West Jefferson's two CIDRs move out of source code and
into that site's own configuration - which is what ADR-015 asks for - and a site
that sets nothing still works, because the script falls back to the NIC carrying
the default route.

EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and
EventSaver.ini, no compiled .scr - a binary is a release asset, like the
installer exe. The share path that was compiled into Config.Folder is gone. It
used to be the fallback when the ini was missing, which silently pointed a new
site at the reference site's file server; it is now empty, and failing visibly
beats displaying another site's slides. Verified by compiling the edited source
in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0.

Also: the DSC example in the adoption guide gains a CollectorRanges resource and
stops passing -ApiUrl to a script that already reads BaseUrl from the registry
the same example writes, and the guide points at the generated download instead
of hand-editing a URL.

The contract test caught the endpoint importing shopdb directly for the version
string, which ADR-002 forbids a plugin from doing. The product and contract
versions are in app.config now, which a plugin reads through current_app.

Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and
let the bay install them, with what the fleet data says about drivers - HP and
Xerox cover 41 of 44 printers with universal drivers, there are no Brother
printers at all despite 208 files of Brother inkjet drivers in the installer,
and printerdrivers holds one row pointing at a per-model folder instead of a
universal driver.
This commit is contained in:
cproudlock
2026-08-18 15:51:14 -04:00
parent 96f127f8c8
commit 2083029ff2
13 changed files with 1169 additions and 16 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>