Plugin framework maturation, reports overhaul, theming, and USB frontend repair

Framework:
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin
  carries its own chain with a stamp-only anchor at the ownership cutover;
  new plugin schema lands in plugins/<name>/migrations/, never the core
  chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the
  shared alembic template (engine URL resolution) and taught the metadata
  filter to include FK-referenced core tables.
- Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin;
  a disabled plugin's pages redirect to the dashboard via a cached,
  fail-open check against the new public GET /api/plugins/enabled.
- get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute
  report cards; warranty and toner cards moved off the hardcoded list.

Reports:
- Hub grouped by category with search; inline reports render at the top,
  are URL-backed (?report=id, back-button and deep links work), expose
  their server-side filter params as controls, and export CSV. Warranty
  and Toner pages gained CSV export.
- Deleted the dead legacy Warranty Status report (always-zero buckets
  from a retired column).

Theming and fonts:
- Inter (variable) bundled locally via @fontsource, replacing the Google
  Fonts Roboto import - air-gapped installs now render correctly; tables
  use tabular numerals.
- Optional brand_primary_dark_color, brand_accent_color,
  brand_sidebar_color settings applied to CSS vars at bootstrap.

USB frontend repair (views were reading a dead legacy shape):
- List/detail/form and the employee profile USB panels remapped to the
  real API shape (device_id/device_desc/checkinoutlog); employee panels
  now use /usb/checkouts endpoints; external-mode /usb/checkouts/active
  honors the badge filter; dead client methods pruned.

Also: warranties list page no longer requires login (matches app
convention); collector doc rewritten with a GE-Enforce integration guide
and paste-ready PowerShell reporter; ADR index and CHANGELOG updated.

Verified: 323 tests pass, naming/style green, frontend builds, plugin
migration dry-run green on scratch MySQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 10:01:47 -04:00
parent b8c22244a1
commit 22e623c1f6
85 changed files with 3274 additions and 972 deletions

View File

@@ -1,67 +1,156 @@
# Collector integration (PC auto-update)
How the shopfloor PC fleet pushes inventory into shopdb-flask, replacing the
classic ASP `api.asp?action=updateCompleteAsset` path.
classic ASP `api.asp?action=updateCompleteAsset` path. The generic collector
contract is defined in ADR-006; this doc is the operational reference for wiring
a real caller (the GE-Enforce fleet agent) to it.
## Endpoint
- Server code: `shopdb/core/api/collector.py`
- Computers schema + upsert: `plugins/computers/plugin.py`
(`get_collector_schema` / `apply_collector_payload`)
- Contract rationale: `docs/adr/ADR-006-collector-contract.md`
`POST /api/collector/computers`
---
Auth: API key header `X-API-Key: <key>`, resolved as `COLLECTOR_API_KEY_COMPUTERS`
then the shared `COLLECTOR_API_KEY` (ADR-006). Idempotent upsert keyed on
`hostname`.
## 1. How it works now
> **Breaking change:** the API key must be sent in the `X-API-Key` header. The
> old `?api_key=<key>` querystring fallback has been removed, on every collector
> endpoint (`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`,
> `/status`). Querystring keys leak into access logs and proxy history. Update
> any caller still passing `api_key` in the URL to use the header instead.
### Auth model (header-only, env keys, fail-closed)
## Payload (project naming convention: lowercase concatenated)
- The API key travels in the `X-API-Key` request header. Nothing else is
accepted. The old `?api_key=<key>` querystring fallback has been removed on
every collector endpoint (see the breaking-change section below).
- Keys come from environment variables (never the database, never a file the app
serves):
- `COLLECTOR_API_KEY_<PLUGINNAME>` - per-plugin override, uppercased plugin
name (e.g. `COLLECTOR_API_KEY_COMPUTERS`).
- `COLLECTOR_API_KEY` - shared fallback used when no per-plugin key is set.
- Resolution order per request: per-plugin key first, then the shared key
(`_plugin_api_key` in `collector.py`).
- Fail-closed: if neither variable is set server-side, the endpoint returns
HTTP 500 `Collector API key not configured` and rejects every request. An
unconfigured server never silently accepts unauthenticated data.
- A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`.
| Field | Meaning | Flask target |
|-------|---------|--------------|
| `hostname` (required) | identity | `Computer.hostname` |
| `machinenumber` | machine number | `Asset.assetnumber` (skips `9999` placeholder, falls back to hostname) |
| `pctype` | imaging pc-type | `Computer.computertypeid` via the configurable mapping |
| `pcsubtype` | finer class | accepted, not yet stored (warning) |
| `serialnumber` | BIOS serial | `Asset.serialnumber` |
| `loggedinuser` | current user | `Computer.loggedinuser` |
| `lastboottime` | ISO datetime | `Computer.lastboottime` |
| `lastcheckin` | ISO datetime | accepted (heartbeat) |
| `ipaddress` | primary IP | primary `Communication` |
| `vendorname` | manufacturer | `Computer.vendorid` (created if missing) |
| `modelnumber` | model | `Computer.modelnumberid` (created if missing) |
| `osname` | OS caption | `Computer.osid` (looked up; warned if unknown) |
| `installedsoftware` | `[{name, version}]` | `ComputerInstalledApp` (known apps only) |
### Generic endpoint contract: `POST /api/collector/<plugin>`
Response: `{status, action: created|updated, assetid, identityvalue, warnings[]}`.
One dynamic route serves every enabled plugin that returns a collector schema.
For the PC fleet that is `POST /api/collector/computers`.
## Source of truth on the PC (current method, may change)
Request flow inside `generic_collect`:
The data already exists at image time and at runtime:
1. Look up the plugin's schema. Unknown / disabled plugin -> HTTP 404
`No collector registered for plugin <plugin>`.
2. Resolve and check the API key (per-plugin then shared, as above).
3. Parse the JSON body. Missing or non-object body -> HTTP 400 `No data provided`.
4. Identity-field resolution: the schema names an `identityfield`
(`hostname` for computers). If that field is missing or blank in the payload,
HTTP 400 `<identityfield> is required`.
5. Idempotent upsert: the plugin's `apply_collector_payload` finds the existing
asset by identity and updates it, or inserts a new one. Same identity on a
later submission updates the same row - re-imaging a PC does not duplicate it,
and existing asset relationships are preserved.
6. Audit logging: every accepted submission writes an `AuditLog` row
(`action` = created/updated, entity `Collector`, `details = collector:<plugin>
action=<action>`), then commits.
- **machine number**: registry `HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\Dnc\General\MachineNo`
FIRST (authoritative post Update-MachineNumber; ignore the `9999` placeholder),
then `C:\Enrollment\machine-number.txt` as fallback. This is exactly what
GE-Enforce.ps1 already does.
- **pc-type / pc-subtype**: `C:\Enrollment\pc-type.txt` / `pc-subtype.txt`
(the `gea-shopfloor-*` taxonomy).
- **serial / vendor / model / os / user / boot**: live WMI on the PC.
Response body (HTTP 200), wrapped in the standard envelope
`{status, data, message, meta}`, with the collector result under `data`:
GE-Enforce currently writes a status JSON to the SFLD share rather than POSTing.
Whatever transport is used (a relay reading those status files, or a direct POST
later), map its field names to the table above.
```json
{
"status": "success",
"data": {
"status": "ok",
"action": "created",
"assetid": 12345,
"identityvalue": "WJRP2335",
"warnings": ["unknown operating system: Microsoft Windows 11 Enterprise 23H2 (build 22631)"]
},
"message": "computers collector created",
"meta": { "timestamp": "...", "requestid": "..." }
}
```
## pc-type mapping (configurable)
`action` is `created`, `updated`, or `noop`. `warnings` is a list of soft
problems (unmapped pc-type, unknown OS, unknown app, un-stored pcsubtype) that
did NOT fail the request - the row was still written.
`pctype` (e.g. `gea-shopfloor-cmm`) is mapped to a flask Computer Type through
### Error responses
Errors use the same envelope with `status: "error"` and the detail under
`data.error`:
```json
{ "status": "error", "data": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } }, "meta": { } }
```
The plugin can raise `ValueError` for controlled validation failures; its message
is returned verbatim (HTTP 400). Any other exception is caught, logged
server-side, the transaction is rolled back, and the caller gets a generic
HTTP 500 `Internal error processing collector payload` with no internal detail
(by design - check the server log to see what actually failed).
### Schema discovery: `GET /api/collector/_schemas`
Returns the collector schema for every enabled plugin. This route is
JWT-protected (an interactive/admin token), NOT API-key protected - it is for
humans and tooling introspecting what payloads are accepted, not for the
headless collectors themselves.
### Legacy endpoints (computers-only, predate ADR-006)
These still exist for the older PowerShell callers and all require the same
`X-API-Key` header:
| Endpoint | Purpose |
|---|---|
| `POST /api/collector/pc` | Update one PC matched by `hostname` (lastboot, user, serial). |
| `POST /api/collector/apps` | Update installed apps for a PC (known apps only). |
| `POST /api/collector/heartbeat` | Record check-in for one or many hostnames. |
| `POST /api/collector/bulk` | Update many PCs in one call. |
| `GET /api/collector/status` | Liveness + endpoint list. |
New integrations should target `POST /api/collector/computers`, not these. Per
ADR-006 the legacy `/pc` path is deprecated and slated for removal before v1.0.
### Computers plugin field mapping
Payload naming follows the project convention (lowercase concatenated). The
identity field is `hostname`. All other fields are optional; an omitted field
leaves the existing value untouched (patch-style), so a bare report never blanks
a column.
| Payload field | Type | Server behaviour (`apply_collector_payload`) |
|---|---|---|
| `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. |
| `machinenumber` | string | Business tag -> `Asset.assetnumber`. The placeholder `9999` and empty string are skipped; when skipped a new PC falls back to `assetnumber = hostname`. On an existing PC a real value updates `assetnumber`. |
| `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. |
| `pcsubtype` | string | Accepted but not stored yet -> warning. |
| `serialnumber` | string | `Asset.serialnumber`. |
| `loggedinuser` | string | `Computer.loggedinuser`. (`currentuser` is also accepted as a legacy alias.) |
| `lastboottime` | ISO-8601 datetime | `Computer.lastboottime`. Unparseable -> warning. |
| `lastcheckin` | ISO-8601 datetime | Accepted (heartbeat semantics). `lastreporteddate` is set server-side on every call regardless. |
| `ipaddress` | string | Primary `Communication` row (`isprimary=True`, type `IP`). Updated in place or created. |
| `vendorname` | string | `Computer.vendorid`. Vendor row auto-created if missing (free vocab). |
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. |
| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). |
| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`.
If you change the payload, change it there and re-check this table.
### pc-type mapping (configurable per site)
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
`pctypemap_<pxetype>` settings (Settings > System > "Collector PC Type Mapping").
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
install; edit per site in the UI. Unmapped pc-types are recorded as a warning,
not an error.
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
failure.
## Classic api.asp field mapping (for migrating the PowerShell scripts)
### Classic api.asp field mapping (for porting the PowerShell reporter)
The fleet's classic-ASP reporter posts form fields to
`api.asp?action=updateCompleteAsset`. Map them to the collector JSON as follows:
| Classic `updateCompleteAsset` form field | Collector field |
|---|---|
@@ -76,5 +165,365 @@ not an error.
| `osVersion` | `osname` |
| `installedApps` | `installedsoftware` |
Not carried over (no current home): warranty fields, DNC config, multi-NIC
detail beyond the primary IP, VNC/WinRM flags.
Not carried over (no current home in the computers schema): warranty fields, DNC
config, multi-NIC detail beyond the single primary IP, VNC/WinRM flags. The
classic reporter posts a full `networkInterfaces` array; the collector accepts
only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in
the PowerShell below).
---
## 2. Breaking change: querystring `api_key` removed
The API key must now be sent in the `X-API-Key` header. The `?api_key=<key>`
querystring form has been removed on every collector endpoint
(`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`).
Querystring keys leak into web-server access logs, proxy history, and browser
history. Header-only keeps the secret out of those logs.
Before (no longer works - the key is ignored and the request is rejected 401):
```
POST /api/collector/computers?api_key=SECRET
Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" }
```
After (correct):
```
POST /api/collector/computers
X-API-Key: SECRET
Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" }
```
PowerShell before/after:
```powershell
# BEFORE (broken)
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers?api_key=$key" `
-Method Post -Body $json -ContentType 'application/json'
# AFTER (correct)
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers" `
-Method Post -Body $json -ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $key }
```
Any caller still putting `api_key` in the URL must move it to the header.
---
## 3. GE-Enforce implementation guide
GE-Enforce is the SYSTEM-context fleet agent that runs every enforcement cycle on
each shopfloor PC (scheduled task, at-logon + periodic). It lives OUTSIDE this
repo. The facts below are drawn from the real scripts so the payload matches what
the PC already knows:
- `playbook/shopfloor-setup/common/GE-Enforce.ps1` - the enforcement pass. At the
end of each run it writes a status JSON to
`<share>\_outputs\logs\<hostname>\status.json` (interim transport; the fleet
dashboard reads those files). It logs via `Write-EnforceLog` to
`C:\Logs\Shopfloor\enforce-YYYYMMDD.log`. Its machine-number resolution is:
registry `HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General` then
`HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General`, property `MachineNo`,
skipping the `9999` placeholder, then `C:\Enrollment\machine-number.txt` as
fallback. hostname is `[System.Environment]::MachineName` (live NetBIOS name,
not `$env:COMPUTERNAME`, which goes stale after a post-image rename).
- `playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1` -
the existing HTTP reporter that POSTs to the classic ASP
`api.asp?action=updateCompleteAsset`. It runs every cycle as a `Type=PS1`
manifest entry (DetectionMethod `Always`) under the SYSTEM task, logs to
`C:\Logs\Shopfloor\report-asset-YYYYMMDD.log`, always exits 0, and reads the
same machine number (reg, then `cmm\cmmid.txt`, then `machine-number.txt`) plus
BIOS serial, OS caption, make/model, logged-in user, and corp-NIC IP from WMI.
The function below is the direct-to-flask analogue of this reporter.
- `playbook/shopfloor-setup/Shopfloor/lib/Update-MachineNumber.ps1` - keeps the
reg `MachineNo` and `C:\Enrollment\machine-number.txt` in sync when a tech
reassigns a bay, so both sources agree.
### Paste-ready reporter
Drop this in as a `Type=PS1` / DetectionMethod `Always` manifest entry (mirroring
`Report-AssetToShopDB.ps1`), or dot-source `Send-ShopdbCollectorReport` from
GE-Enforce.ps1 and call it at the end of the pass. It reads the machine number
exactly the way GE-Enforce already does, builds a payload matching the computers
collector schema, and POSTs with the `X-API-Key` header over TLS 1.2. Every
field name below was checked against `get_collector_schema` in
`plugins/computers/plugin.py`.
```powershell
# Send-ShopdbCollectorReport.ps1
# Reports this PC's identity to shopdb-flask via POST /api/collector/computers.
# Runs as SYSTEM from the GE-Enforce cycle. Always exits without throwing;
# failures are logged, never fatal.
function Get-ShopdbCollectorApiKey {
# NEVER hardcode the key in this script (it lives on the share). Read it
# from a per-site value the SYSTEM/machine account can already reach.
# Preferred: a field in the site-config.json GE-Enforce already parses.
# Fallback: a machine-local file staged at imaging, ACL'd to SYSTEM.
$key = ''
$siteCfg = 'C:\Enrollment\site-config.json'
if (Test-Path -LiteralPath $siteCfg) {
try {
$cfg = Get-Content -LiteralPath $siteCfg -Raw | ConvertFrom-Json
if ($cfg.collectorApiKey) { $key = "$($cfg.collectorApiKey)".Trim() }
} catch {}
}
if (-not $key) {
$keyFile = 'C:\Enrollment\collector-api-key.txt'
if (Test-Path -LiteralPath $keyFile) {
try { $key = (Get-Content -LiteralPath $keyFile -First 1 -ErrorAction Stop).Trim() } catch {}
}
}
return $key
}
function Get-ShopdbMachineNumber {
# Same resolution GE-Enforce.ps1 / Report-AssetToShopDB.ps1 use:
# eDNC registry (skip 9999 placeholder) then C:\Enrollment\machine-number.txt.
$machineNumber = ''
foreach ($rp in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General'
)) {
if ($machineNumber) { break }
if (Test-Path $rp) {
try {
$v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
if ($v -and "$v".Trim() -ne '9999') { $machineNumber = "$v".Trim() }
} catch {}
}
}
if (-not $machineNumber -and (Test-Path 'C:\Enrollment\machine-number.txt')) {
try {
$v = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction Stop).Trim()
if ($v -and $v -ne '9999') { $machineNumber = $v }
} catch {}
}
return $machineNumber
}
function Get-ShopdbCorpIPv4 {
# Pick the corp/AESFMA NIC IP. Same allowed-range gate as
# Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs.
$allowedRanges = @(
@{ Network = '10.134.48.0'; PrefixLen = 23 },
@{ Network = '10.48.249.0'; PrefixLen = 26 }
)
function ConvertTo-Uint32([string]$ip) {
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
[Array]::Reverse($bytes)
return [BitConverter]::ToUInt32($bytes, 0)
}
try {
$ips = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
foreach ($ipo in $ips) {
$ipInt = ConvertTo-Uint32 $ipo.IPAddress
foreach ($r in $allowedRanges) {
$netInt = ConvertTo-Uint32 $r.Network
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $ipo.IPAddress }
}
}
} catch {}
return ''
}
function Send-ShopdbCollectorReport {
param(
[string]$SiteHost = 'tsgwp00525.wjs.geaerospace.net',
[string]$ApiKey = (Get-ShopdbCollectorApiKey),
[int]$TimeoutSec = 30,
[string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
)
$logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
function Write-CollectorLog([string]$msg) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$ts $msg" | Tee-Object -FilePath $LogFile -Append | Out-Null
}
Write-CollectorLog '=== Report to shopdb-flask collector ==='
if (-not $ApiKey) {
Write-CollectorLog 'ERROR no collector API key (site-config.json / collector-api-key.txt) - skipping.'
return
}
# PowerShell 5.1 does not always negotiate TLS 1.2 by default.
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
# --- Gather identity (matches the computers collector schema) ---
$hostname = [System.Environment]::MachineName
if (-not $hostname) { $hostname = $env:COMPUTERNAME }
$machineNumber = Get-ShopdbMachineNumber
$ipAddress = Get-ShopdbCorpIPv4
$serialNumber = ''
try {
$serialNumber = "$((Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber)".Trim()
} catch { Write-CollectorLog "WARN BIOS serial read failed: $($_.Exception.Message)" }
$vendorName = ''; $modelNumber = ''; $loggedInUser = ''
try {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
$vendorName = "$($cs.Manufacturer)".Trim()
$modelNumber = "$($cs.Model)".Trim()
# UserName is COMPUTERNAME\user or DOMAIN\user; keep the bare username.
if ($cs.UserName) { $loggedInUser = ($cs.UserName -split '\\')[-1].Trim() }
} catch { Write-CollectorLog "WARN ComputerSystem read failed: $($_.Exception.Message)" }
$osName = ''; $lastBootTime = ''
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$osName = "$($os.Caption)".Trim()
$dv = ''
try { $dv = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion } catch {}
if ($dv) { $osName += " $dv" }
if ($os.BuildNumber) { $osName += " (build $($os.BuildNumber))" }
$osName = $osName.Trim()
# ISO-8601 so the server's datetime parse accepts it.
try { $lastBootTime = $os.LastBootUpTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch {}
} catch { Write-CollectorLog "WARN OS read failed: $($_.Exception.Message)" }
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
try { $pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
}
$pcSubType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-subtype.txt') {
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
}
# --- Build payload. Field names MUST match get_collector_schema exactly. ---
$payload = @{ hostname = $hostname }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
if ($serialNumber) { $payload['serialnumber'] = $serialNumber }
if ($loggedInUser) { $payload['loggedinuser'] = $loggedInUser }
if ($lastBootTime) { $payload['lastboottime'] = $lastBootTime }
if ($ipAddress) { $payload['ipaddress'] = $ipAddress }
if ($vendorName) { $payload['vendorname'] = $vendorName }
if ($modelNumber) { $payload['modelnumber'] = $modelNumber }
if ($osName) { $payload['osname'] = $osName }
$payload['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
# installedsoftware is optional; add an array of @{ name=..; version=.. }
# here if the manifest introspection is wired to real Application names.
$uri = "https://$SiteHost/api/collector/computers"
$json = $payload | ConvertTo-Json -Depth 5
$headers = @{ 'X-API-Key' = $ApiKey }
Write-CollectorLog ("POST {0} host={1} machineNo={2} pcType={3} serial={4} ip={5}" -f `
$uri, $hostname, $machineNumber, $pcType, $serialNumber, $ipAddress)
try {
$resp = Invoke-RestMethod -Uri $uri -Method Post -Body $json `
-ContentType 'application/json' -Headers $headers `
-TimeoutSec $TimeoutSec -ErrorAction Stop
$d = $resp.data
Write-CollectorLog ("OK action={0} assetid={1} identity={2} warnings={3}" -f `
$d.action, $d.assetid, $d.identityvalue, ((@($d.warnings)) -join '; '))
} catch {
$code = $null
try { $code = [int]$_.Exception.Response.StatusCode.value__ } catch {}
Write-CollectorLog "ERROR POST failed (http=$code): $($_.Exception.Message)"
}
}
# Entry point when run as a standalone manifest PS1 entry:
Send-ShopdbCollectorReport
```
### Where the call slots into the GE-Enforce run
Put the POST at the END of the enforcement pass, after the enforcement scopes are
processed and the PC identity is settled. Two placements, pick one:
- Preferred - a sibling manifest entry, mirroring `Report-AssetToShopDB.ps1`:
add `Send-ShopdbCollectorReport.ps1` as a `Type=PS1`, DetectionMethod `Always`
manifest entry in `common\manifest.json`. It runs every cycle under the SYSTEM
task, keeps GE-Enforce.ps1 untouched, and gets its own log file. This matches
the established pattern the classic reporter already uses.
- Alternative - inline: dot-source the function and call
`Send-ShopdbCollectorReport` inside GE-Enforce.ps1's status-write-back `try`
block (right after `status.json` is written, near the end of the pass). Use
this only if you want the HTTP push to share GE-Enforce's own log and lifecycle.
Either way, run it ALONGSIDE the existing `status.json` share write (and
alongside the classic `Report-AssetToShopDB.ps1`) during rollout - do not remove
the share write until the HTTP path is proven.
### Delivering the API key to clients
Do NOT hardcode the key in the script - the script itself lives on the SFLD
share, so a literal key there is effectively published to everyone with share
read. Two viable options:
1. Per-site value the machine account already reads. GE-Enforce already parses
`C:\Enrollment\site-config.json` (its local copy of the per-site config on the
share). Add one field, `collectorApiKey`, populated per site. The script reads
it with the machine/SYSTEM identity it already runs as. Rotation = update the
one config value at the site, no re-image, no script redeploy.
2. Baked at imaging into a machine-local file (`C:\Enrollment\collector-api-key.txt`)
or an HKLM value, ACL'd to SYSTEM/Administrators, written by the enrollment
pipeline. Tighter blast radius (the key never sits on the share at all), but
rotation requires touching every PC or re-imaging.
Recommendation: use option 1 (`collectorApiKey` in the per-site config), matching
how GE-Enforce already sources its share paths and mirroring how SFLD credentials
are provisioned per site. It keeps the secret out of source control and off the
script, sits at the same trust boundary as everything else the SYSTEM agent
already reads, and supports rotation without a fleet touch. `Get-ShopdbCollectorApiKey`
above already prefers this and falls back to the imaging-baked file, so a site
can start on option 1 and tighten to option 2 later with no code change. The
server side pairs this with `COLLECTOR_API_KEY_COMPUTERS` (per-plugin) so the PC
fleet's key is scoped to the computers collector only.
### Staged rollout
1. Keep everything as-is: `status.json` share write + classic
`Report-AssetToShopDB.ps1` continue running. Provision `collectorApiKey` per
site and set `COLLECTOR_API_KEY_COMPUTERS` on the shopdb-flask server.
2. Add `Send-ShopdbCollectorReport` as a new manifest entry on a handful of pilot
bays (one per pc-type: collections, cmm, keyence, waxtrace, ...). It logs to
`C:\Logs\Shopfloor\collector-YYYYMMDD.log` and cannot break enforcement (it
never throws, exits cleanly).
3. Verify on the server: pilot PCs appear/update in shopdb-flask, `action` is
`created`/`updated`, warnings are understood (map any unmapped `pctype`, add
any missing OS strings to the `operatingsystems` vocab). Cross-check the
collector log's `OK` lines against the audit log.
4. Roll the manifest entry out fleet-wide (it deploys from `common\`, so every
pc-type gets it). Continue running both the share write and the HTTP POST.
5. Once shopdb-flask is the system of record and stable across a full imaging
cycle, retire the classic `Report-AssetToShopDB.ps1` and, if desired, the
`status.json` share write.
---
## 4. Troubleshooting
| Symptom | HTTP | Cause / fix |
|---|---|---|
| `Invalid API key` | 401 | The `X-API-Key` header is missing or wrong. Confirm the client key matches `COLLECTOR_API_KEY_COMPUTERS` (or the shared `COLLECTOR_API_KEY`) on the server. Check the key is being read (`Get-ShopdbCollectorApiKey` returned non-empty). Remember the querystring form no longer works. |
| `Collector API key not configured` | 500 | Fail-closed: neither `COLLECTOR_API_KEY_COMPUTERS` nor `COLLECTOR_API_KEY` is set in the server environment. Set one and restart the app. This is a SERVER config gap, not a client problem. |
| `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. |
| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. |
| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. |
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`.
Server-side: the Flask app log (the collector logs upsert failures and per-plugin
schema failures there).