3 Commits

Author SHA1 Message Date
cproudlock
2083029ff2 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.
2026-08-18 15:51:14 -04:00
cproudlock
96f127f8c8 Bring the collector script into the repo, with no site in it
Report-AssetToShopDB.ps1 lived on one site's imaging share and was, per the
adoption guide, "provided on request" - which is not a distribution mechanism for
a product meant to be adopted. It now lives in plugins/computers/client/, beside
the collector contract it implements (ADR-006), so the two version together.

Three things named West Jefferson and no longer do (ADR-015):

- The server. It resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl - the value
  Install-GEEnforce.ps1 already writes and the enforcement client cannot run
  without - or from -ApiUrl. With neither it logs and exits 0 rather than
  posting somewhere wrong. Any bay running this script runs the enforcement
  client, so the value is present wherever it is deployed.
- The corporate VLANs. Two hardcoded CIDRs decided which NIC's address was
  reported, with a comment reading "update if site re-VLANs". A site may now
  name its ranges (-AllowedRanges, or a CollectorRanges registry value); with
  none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
  "the routable NIC, not the controller NIC" without knowing any site's
  addressing.

VERIFIED IN THE WINDOWS VM, not by reading it - and the VM earned its keep. The
local array was called $allowedRanges, which is the SAME VARIABLE as the [string]
parameter $AllowedRanges because PowerShell names are case-insensitive; the array
was silently coerced to an empty string, and .Count on a scalar string is 1. The
script therefore believed a range was configured, skipped the default-route
fallback, and reported no IP at all. Linux pwsh parsed it happily. Renamed to
$rangeList, and the four paths were then confirmed on Windows: no config skips
cleanly, BaseUrl resolves from the registry, an unconfigured site picks the
default-route NIC, a configured range selects or excludes as asked.
2026-08-18 09:59:51 -04:00
cproudlock
f34b9ca710 Carry the level everywhere a position is drawn, and gate it per occurrence
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
The hover mini-map said "This asset has a position (2835, 1410) but no level"
for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid
prop, NONE of its seven call sites were taught to pass one - printer, machine and
PC detail pages, the toner report, enforcement reports, the warranty chip and the
dashboard cards - so the component correctly reported a missing level and the
preview never drew. Two payloads behind those views also emitted mapx/mapy with
no level: the toner report and the enforcement report.

The map PDF export had the ORIGINAL bug still in it: it plotted every filtered
asset onto the sheet, so exporting the ground floor printed second-floor markers
on it. Worse than on screen, because nobody can correct a sheet once it has been
printed and carried onto the floor. It now exports only the level being viewed.

The legacy import loader sent mapleft/maptop with no level at three call sites.
That loader is the one still to run against production, and every marker it
created would have been undrawable. It now resolves the site's default level -
the legacy schema predates levels and has one floor plan, so that is what its
coordinates mean.

THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid',
not whether each position does: one module emitted 'mapx' six times and 'levelid'
once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/
and plugins/, and fails any Vue file that binds tooltip coordinates without
:levelid. Both new rules were confirmed to fail the build against planted
violations before being relied on.

Printer QR labels: the asset number is no longer printed. A label now reads name
(8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber
because that is where sites actually keep it - every printer here has an empty
name field, so preferring the Windows queue name alone would have printed a blank
line on every label.
2026-08-18 09:36:45 -04:00
30 changed files with 2014 additions and 417 deletions

View File

@@ -22,12 +22,12 @@ at all yet.
| Artifact | Where it is today | | Artifact | Where it is today |
|---|---| |---|---|
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. | | GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
| `Report-AssetToShopDB.ps1` | **Not in this repository.** It lives on the reference site's imaging share and is provided on request. It is planned to move to `plugins/computers/client/` so it versions with the collector contract it implements. | | `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. |
| EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). | | EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). |
Ask the maintainers for the two that are not here. A site that would rather not A site that would rather not run a binary it cannot rebuild should take
run a binary it cannot rebuild should take EventSaver's source and compile it EventSaver's source and compile it locally - the build needs no SDK and is one
locally - the build needs no SDK and is one command. command.
--- ---
@@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone.
| Input | Where it comes from | | Input | Where it comes from |
|---|---| |---|---|
| Server URL | `-ApiUrl https://<your-shopdb>/api/collector/computers` | | Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it |
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` | | API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script |
| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites |
**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter
generates this script with your server's URL and ranges already in the parameter
defaults, and shows its SHA-256. The key is deliberately not included.
### Minting the key on your ShopDB ### Minting the key on your ShopDB
@@ -142,13 +147,27 @@ Configuration ShopdbCollector
Ensure = 'Present' Ensure = 'Present'
} }
# Optional. Only for a site whose bays carry both a controller NIC and a
# corporate one AND whose default route is not the corporate NIC. Leave
# this resource out otherwise - the script picks the default-route NIC.
Registry CollectorRanges
{
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
ValueName = 'CollectorRanges'
ValueData = '10.20.0.0/23,10.21.4.0/26'
ValueType = 'String'
Ensure = 'Present'
}
Script ReportingTask Script ReportingTask
{ {
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } } GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
SetScript = { SetScript = {
# No -ApiUrl: BaseUrl above is where the script reads it from,
# and one source beats two that can disagree.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' ` $action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"' -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 7am $trigger = New-ScheduledTaskTrigger -Daily -At 7am
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action ` Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force -Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force

View File

@@ -38,10 +38,12 @@ because the failure would otherwise be visible to the whole floor.
**Folder mode** predates the server and stays as a fallback for a site with no **Folder mode** predates the server and stays as a fallback for a site with no
ShopDB instance yet, or for content nobody wants in the database. ShopDB instance yet, or for content nobody wants in the database.
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the > If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
> binary falls back to a path compiled into `EventSaver.cs` - and that path > no source to read and the screensaver shows nothing. That is deliberate: the
> belongs to the site it was first built for. Ship the ini. A missing ini is not > compiled-in fallback used to be the path of the site it was first built for, so
> a neutral default. > a missing ini silently pointed a new site at someone else's file server. It is
> now empty, and failing visibly beats displaying the wrong site's slides. Ship
> the ini.
## What decides the running order ## What decides the running order
@@ -98,8 +100,9 @@ covering something someone needed to see.
## Building it ## Building it
No SDK required - it compiles with the in-box .NET Framework compiler on any The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
Windows 10 or 11 machine: compiles with the in-box .NET Framework compiler on any Windows 10 or 11
machine:
``` ```
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^ C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^

View File

@@ -1697,6 +1697,14 @@
"params": "protocol_id in path; body: any of protocolname, port, isactive", "params": "protocol_id in path; body: any of protocolname, port, isactive",
"purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against", "purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against",
"example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3" "example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3"
},
{
"method": "GET",
"path": "/api/computers/client-script",
"auth": "jwt + role:admin",
"params": "none",
"purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script"
} }
], ],
"surface": "plugin-computers" "surface": "plugin-computers"

View File

@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "ShopDB Flask API", "title": "ShopDB Flask API",
"version": "0.10.0", "version": "0.11.2",
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither." "description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
}, },
"servers": [ "servers": [
@@ -9892,6 +9892,44 @@
} }
} }
}, },
"/api/computers/client-script": {
"get": {
"tags": [
"plugin-computers"
],
"summary": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the...",
"description": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched\n\n**Auth:** jwt + role:admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/measuringtools/types": { "/api/measuringtools/types": {
"get": { "get": {
"tags": [ "tags": [

View File

@@ -0,0 +1,161 @@
# Proposal: assign printers to a PC in ShopDB, let the PC install them
Status: PROPOSED. Not built.
Author: planning session 2026-08-18.
## 1. What this is
Today a printer reaches a shop-floor PC because a person walks up to it, opens
the printer installer, finds the printer on a floor plan and clicks it. That is
fine for someone choosing a printer, and wrong for a bay whose printers are a
property of the bay.
This proposal makes the assignment data: edit a PC in ShopDB, tick the printers
that belong on it, mark one default. The PC converges on its next GE-Enforce
cycle - installing what is missing and setting the default - and keeps
converging, so a reimaged bay comes back with its printers and a bay that drifts
is corrected.
The map installer stays, for the case it is actually good at: a person at an
unmanaged or office PC picking a printer that nobody assigned.
## 2. Why it is worth doing
- **The assignment becomes a record.** "Which printers does bay 2107 have" is a
question ShopDB can answer, and today it cannot.
- **A reimage stops costing a visit.** The bay reinstalls its own printers.
- **Drift is corrected, not just detected.** A queue deleted by a user comes
back.
- **It removes the walk-up from the common case.** The installer's map remains
for the uncommon one.
## 3. What already exists
Most of the model is in place, which is why this is a small feature rather than
a project.
| piece | state |
|---|---|
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) |
| Per-printer install path | `Printer.installpath` |
| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` |
| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle |
| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` |
## 4. What has to be built
### 4.1 One endpoint
```
GET /api/printers/for-host/<hostname>
```
Returns the printers assigned to that PC and which is default, each with what a
client needs to install it: queue name, host or IP, port, driver name, driver
location.
Resolved by hostname, not machine number: the collector already upserts PCs by
hostname, and an office PC has no machine number.
**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope
and PC type and sync broadly; putting per-PC rows there would leak every bay's
configuration to every bay and grow without limit. One manifest entry runs one
script that asks the API what THIS host gets - the mirror image of
`Report-AssetToShopDB.ps1`.
### 4.2 Two columns on `printerdrivers`
- `drivername` - the driver's exact name as the INF declares it, e.g.
`HP Universal Printing PCL 6`. `Add-PrinterDriver` needs it verbatim, and a
mismatch is the usual failure. Deriving it by parsing the INF on hundreds of
bays is fragile; a human confirming it once in ShopDB is not.
- `installmethod` - `pnputil` or `dpinst`. See section 6: if Brother really is
absent from the fleet, everything is `pnputil` and this column can wait.
### 4.3 UI on the PC form
A printer picker writing `defaultprinter` (one) and an assignment list (many).
`AssetRelationships.vue` already edits relationships; this is a narrowed case of
it.
### 4.4 One client script, in two contexts
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
*As SYSTEM, every cycle:*
1. `GET /api/printers/for-host/$env:COMPUTERNAME`
2. For each assigned printer with no queue: trust the driver catalog's cert,
`pnputil /add-driver`, create the port, create the queue
3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter`
4. Ensure the per-user task exists
*In the user's context, at logon and on a repeat:*
5. Read that value, compare with the current default, set it if it differs, and
clear "Let Windows manage my default printer" - otherwise Windows silently
overrides the choice the next time someone prints elsewhere
The default printer is per-user state, which is the only reason this needs two
contexts. Everything else is machine state and belongs to the cycle.
Converge, do not reinstall: when the state matches, the script does nothing.
Nothing here needs the manifest to know when a printer changes, because the
desired state is fetched, not declared.
## 5. Decisions to take before writing code
1. **Never remove a queue by default.** A transient API failure would otherwise
strip printers fleet-wide. Deletion is an explicit opt-in, per PC.
2. **Enforced or set-once for the default?** Re-applying every cycle overrides a
user who chose their own default - correct for a locked bay, irritating on an
office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag
rather than one global answer.
3. **Failure is silent and safe**: unreachable API means change nothing, log,
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
## 6. What the fleet data says, and the one prerequisite
The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1.
- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD,
Xerox Global Print Driver). One driver record each serves every queue of that
make.
- **There are no Brother printers at all**, yet the installer carries 208 files
of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices
with no Printer-class INF, which is the only reason a second staging method
(DPInst) exists. If production confirms no Brother, that payload and that code
path can both go.
- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is
a fourth special case - a PostScript device the UPD does not cover.
**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it
points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the
universal driver - the opposite of how a UPD should be used. The table needs
roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its
payload is restored. Nothing in this proposal works until a printer can resolve
to a driver.
## 7. Deployment constraint that shapes the design
**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching
a share path must run as a manifest entry inside that cycle, never as its own
scheduled task. The failure is silent - the task reports 0 processed, 0
installed, 0 failed - and it has cost a session before.
This is why driver staging belongs in the cycle even though the per-user default
does not, and why "the assignment script schedules a task that installs drivers"
is the wrong shape.
## 8. What this does not change
- The printer installer keeps working, for walk-up and self-service.
- Nothing about how printers are modelled, mapped or reported.
- The collector contract.
- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.

View File

@@ -22,7 +22,7 @@
<!-- With coordinates, the name carries the same floor-plan preview <!-- With coordinates, the name carries the same floor-plan preview
the asset's own page uses. Without them, a plain link. --> the asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover" <LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label"> :machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title" <router-link :to="row.link" class="dc-row-title"
:title="row.titletip || undefined"> :title="row.titletip || undefined">

View File

@@ -127,7 +127,15 @@ export function mapHover(card, item) {
const x = item[spec.x] const x = item[spec.x]
const y = item[spec.y] const y = item[spec.y]
if (x === null || x === undefined || y === null || y === undefined) return null if (x === null || x === undefined || y === null || y === undefined) return null
return { x, y, label: spec.label ? (item[spec.label] || '') : '' } // The level travels with the coordinates (ADR-017) - they are pixels of ONE
// drawing. Read `levelid` unless the card names another field, so a card that
// predates levels still previews on the right floor instead of none.
const levelid = item[spec.level || 'levelid']
return {
x, y,
levelid: levelid === undefined ? null : levelid,
label: spec.label ? (item[spec.label] || '') : '',
}
} }
export function cardRows(card) { export function cardRows(card) {

View File

@@ -289,7 +289,12 @@ async function exportPdf() {
try { try {
await loadMapConfig() await loadMapConfig()
await exportMapPdf({ await exportMapPdf({
assets: filteredAssets.value, // Only this level's markers. The sheet is one drawing, so a marker
// positioned against another level would be printed on the wrong floor
// plan - the same failure the on-screen map had, in a form nobody can
// correct after it is printed and carried onto the floor.
assets: filteredAssets.value.filter(
asset => (asset.levelid ?? null) === shownLevelId.value),
// blueprintUrlFor applies withBase - the raw setting value is a // blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops. // root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper. // The PDF always uses the light blueprint: it prints on white paper.

View File

@@ -1,13 +1,13 @@
"""Computers plugin API endpoints.""" """Computers plugin API endpoints."""
from flask import Blueprint, request from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from shopdb.api import require_permission, apply_import_timestamps from shopdb.api import require_permission, require_role, apply_import_timestamps
computers_bp = Blueprint('computers', __name__) computers_bp = Blueprint('computers', __name__)
@@ -1064,3 +1064,112 @@ def dashboard_sharedmachines():
out.sort(key=lambda r: -r['pccount']) out.sort(key=lambda r: -r['pccount'])
return success_response(out) return success_response(out)
# =============================================================================
# Collector client script
# =============================================================================
CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1'
def _client_script_path():
"""The reporter shipped with this plugin, which is the single source."""
import os
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'client', CLIENT_SCRIPT_NAME)
def _setting_value(key):
row = Setting.query.filter_by(key=key).first()
return ((row.value if row else '') or '').strip()
def _generate_client_script(source: str, baseurl: str, ranges: str,
version: str, generatedon: str) -> str:
"""Stamp a site's own values into the reporter's parameter defaults.
ONLY the defaults are substituted, never the body: the file in the repo
stays runnable as-is, so there is no second copy to drift. Everything
stamped here is overridable at runtime - the parameter still wins, then the
registry - because a bay may need to differ from its site.
The collector key is NOT stamped in. This file lands on every shop-floor PC,
and a token in a file on hundreds of bays cannot be rotated quietly; it is
read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.
"""
apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else ''
header = (
'# GENERATED by ShopDB {version} on {generatedon}\n'
'# for {baseurl}\n'
'#\n'
'# Re-download after upgrading ShopDB: this copy matches that server\'s\n'
'# collector contract. Edits here are lost on the next download - change\n'
'# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n'
'#\n'
'# The collector key is deliberately NOT in this file. Provision it as\n'
'# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n'
'\n'
).format(version=version, generatedon=generatedon,
baseurl=baseurl or 'an unconfigured site (set site_base_url)')
out = source
if apiurl:
old = "[string]$ApiUrl = ''"
assert old in out, 'the reporter no longer declares $ApiUrl as expected'
out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1)
if ranges:
old = "[string]$AllowedRanges = ''"
assert old in out, 'the reporter no longer declares $AllowedRanges as expected'
out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1)
return header + out
@computers_bp.route('/client-script', methods=['GET'])
@jwt_required()
@require_role('admin')
def download_client_script():
"""The collector reporter, stamped with THIS site's values.
Admin-only. It carries no secret, but it does state a site's URL and its
internal ranges, which is configuration rather than something to hand out.
"""
import datetime
import hashlib
import os
path = _client_script_path()
if not os.path.isfile(path):
return error_response(ErrorCodes.NOT_FOUND,
'The collector script is not present in this install',
http_code=404)
with open(path, 'r', encoding='utf-8') as handle:
source = handle.read()
# A site that has not set its public URL still gets a usable script: the
# browsing origin is the server the admin is talking to right now.
baseurl = _setting_value('site_base_url') or request.url_root
# From config, not an import: a plugin reaching into core is an ADR-002
# violation and the contract test fails the build for it.
version = current_app.config.get('VERSION') or 'unknown'
generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
try:
body = _generate_client_script(
source, baseurl.strip(), _setting_value('computers_routableranges'),
version, generatedon)
except AssertionError as exc:
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
digest = hashlib.sha256(body.encode('utf-8')).hexdigest()
return Response(
body,
mimetype='text/plain; charset=utf-8',
headers={
'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME),
# Published so a deployment can verify what it fetched, the same way
# the installer publishes one.
'X-Script-Sha256': digest,
})

View File

@@ -0,0 +1,328 @@
# Report-AssetToShopDB.ps1
#
# Reports a PC's identity to ShopDB (Flask) so the computers/machines record
# stays current with whatever the PC actually is right now: hostname, BIOS
# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when
# present) and its corp/AESFMA IPv4 address.
#
# TARGET: the ADR-006 collector API.
# POST <shopdb>/api/collector/computers
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
# which Install-GEEnforce.ps1 provisions and the enforcement client already
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
# product code is a defect, and this script ships to every site.
# The computers plugin's apply_collector_payload upserts idempotently by
# hostname (create if missing, patch-style update if present - only the fields
# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
# key into the manifest JSON on the share.
#
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
# identity with no machineNo, so no PC-to-machine link is built - by design.
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are
# logged, never thrown.
#
# WHY ONE NIC ONLY:
# Some bays carry two NICs - a private controller NIC and the routable
# corporate NIC. Only the routable one belongs in ShopDB. A site may name its
# corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with
# none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
# the same intent without knowing any site's addressing.
param(
# Flask collector endpoint for the computers plugin. Empty resolves from
# HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
[string]$ApiUrl = '',
# Comma-separated CIDRs naming this site's routable ranges, e.g.
# '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead.
[string]$AllowedRanges = '',
# collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
# store in the registry. Override with -ApiKey via Args for testing.
[string]$ApiKey = '',
[int]$TimeoutSec = 30
)
$ErrorActionPreference = 'Continue'
# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
# protocol the site rejects; the collector POST is HTTPS.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
$logFile = Join-Path $logDir ('report-asset-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log([string]$msg) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}
# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
if (-not $ApiUrl) {
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
try {
if (Test-Path $regPath) {
$base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl
if ($base -and $base.Trim()) {
$ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers'
break
}
}
} catch {}
}
}
if (-not $ApiUrl) {
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
exit 0
}
# A site may name its routable ranges rather than rely on the default route.
if (-not $AllowedRanges) {
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
try {
if (Test-Path $regPath) {
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges
if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break }
}
} catch {}
}
}
# collector key from the GE-Enforce secret store when not passed via Args.
if (-not $ApiKey) {
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
try {
if (Test-Path $regPath) {
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey
if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break }
}
} catch {}
}
}
if (-not $ApiKey) {
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
}
# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an
# unconfigured site falls through to the default-route NIC below (ADR-015).
#
# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so
# that collides with the [string] parameter above and the array is silently
# COERCED to a string. .Count on a string is 1, so the script then believes a
# range is configured and never falls back to the default route.
$rangeList = @()
foreach ($cidr in ($AllowedRanges -split ',')) {
$cidr = $cidr.Trim()
if (-not $cidr) { continue }
$parts = $cidr -split '/'
if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue }
$rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] }
}
function ConvertTo-Uint32([string]$ip) {
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
[Array]::Reverse($bytes)
return [BitConverter]::ToUInt32($bytes, 0)
}
function Test-InAllowedRange([string]$ip) {
try {
$ipInt = ConvertTo-Uint32 $ip
foreach ($r in $rangeList) {
$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 $true }
}
} catch {}
return $false
}
Log '=== Report asset to ShopDB (collector) ==='
# hostname - the collector identity field. required.
$hostname = $env:COMPUTERNAME
# BIOS serial - optional now (collector keys on hostname). Sent when present.
$serialNumber = ''
try {
$serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
if ($serialNumber) { $serialNumber = $serialNumber.Trim() }
} catch {
Log "WARN could not read BIOS serial: $($_.Exception.Message)"
}
# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber
# server-side (9999 placeholder skipped there and here).
# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
# 3. C:\Enrollment\machine-number.txt - imaging value.
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
$machineNo = ''
foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
if ($machineNo) { break }
try {
if (Test-Path $regPath) {
$v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
}
} catch {
Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
}
}
if (-not $machineNo) {
$cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
if (Test-Path -LiteralPath $cmmFile) {
try {
$v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
} catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
}
}
if (-not $machineNo) {
$mnFile = 'C:\Enrollment\machine-number.txt'
if (Test-Path -LiteralPath $mnFile) {
try {
$v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
} catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
}
}
# OS name string (caption + feature-update + build), e.g.
# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each
# distinct string into operatingsystems.
$osVersion = ''
$lastBootTime = ''
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$osVersion = "$($os.Caption)".Trim()
$displayVersion = ''
try {
$displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
} catch {}
if ($displayVersion) { $osVersion += " $displayVersion" }
if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" }
$osVersion = $osVersion.Trim()
# ISO 8601 - collector parses via datetime.fromisoformat.
try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
} catch {}
# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName
# (console session owner). empty when nobody logged on -> omitted so an
# unattended bay does not blank the last-known user.
$loggedInUser = ''
try {
$loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
} catch {
Log "WARN could not read logged-in user: $($_.Exception.Message)"
}
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
# when present; absent -> server leaves existing pctype untouched (a bare report
# never re-types a PC). Unmapped values return a warning, not an error.
$pcType = ''
$ptFile = 'C:\Enrollment\pc-type.txt'
if (Test-Path -LiteralPath $ptFile) {
try {
$pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
} catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
}
# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when
# present so a WMI read failure does not blank the model.
$manufacturer = ''
$model = ''
try {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
$manufacturer = "$($cs.Manufacturer)".Trim()
$model = "$($cs.Model)".Trim()
} catch {
Log "WARN could not read make/model: $($_.Exception.Message)"
}
# The routable IPv4 - a physical, connected NIC. The collector schema takes a
# single ipaddress string, so report the corporate NIC and drop any
# controller/machine-LAN NIC. With ranges configured the IP must be in one;
# with none, the NIC carrying the default route is the routable one by
# definition, which needs no knowledge of a site's addressing.
$corpIp = ''
$defaultRouteIfIndexes = @()
if ($rangeList.Count -eq 0) {
try {
$defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
Sort-Object RouteMetric |
Select-Object -ExpandProperty InterfaceIndex -Unique)
} catch {
Log "WARN could not read the route table: $($_.Exception.Message)"
}
}
try {
$ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
foreach ($ipo in $ipObjs) {
if ($corpIp) { break }
$adapter = $null
try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
if (-not $adapter) { continue }
if (-not $adapter.HardwareInterface) { continue }
if ($adapter.Status -ne 'Up') { continue }
if ($rangeList.Count -gt 0) {
if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress }
} elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) {
$corpIp = $ipo.IPAddress
}
}
} catch {
Log "WARN interface enumeration failed: $($_.Exception.Message)"
}
if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' }
# collector schema fields (lowercase concatenated). hostname is required; the
# rest are sent only when present so a partial read never blanks a good value.
$body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo) { $body['machinenumber'] = $machineNo }
if ($pcType) { $body['pctype'] = $pcType }
if ($manufacturer) { $body['vendorname'] = $manufacturer }
if ($model) { $body['modelnumber'] = $model }
if ($osVersion) { $body['osname'] = $osVersion }
if ($lastBootTime) { $body['lastboottime'] = $lastBootTime }
if ($loggedInUser) { $body['loggedinuser'] = $loggedInUser }
if ($corpIp) { $body['ipaddress'] = $corpIp }
$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
$json = $body | ConvertTo-Json -Compress -Depth 4
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $ApiKey } `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
} catch {
Log "ERROR POST failed: $($_.Exception.Message)"
}
exit 0

View File

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

@@ -185,6 +185,7 @@
v-if="computer.mapx != null && computer.mapy != null" v-if="computer.mapx != null && computer.mapy != null"
:left="computer.mapx" :left="computer.mapx"
:top="computer.mapy" :top="computer.mapy"
:levelid="computer.levelid"
:machineName="computer.assetnumber" :machineName="computer.assetnumber"
> >
<span class="location-link">{{ computer.locationname || 'On Map' }}</span> <span class="location-link">{{ computer.locationname || 'On Map' }}</span>

View File

@@ -162,6 +162,25 @@ class ComputersPlugin(BasePlugin):
}, },
} }
def get_settings_cards(self) -> List[dict]:
"""The asset reporter's own settings page.
It is a settings card rather than a docs page because it does two things
an operator needs at the same moment: name this site's routable ranges,
and download the reporter that carries them.
"""
return [
{
'group': 'Computers',
'to': '/settings/collector',
'icon': 'download',
'title': 'Asset reporter',
'description': 'Download the collector script stamped with this '
'site\'s URL and ranges',
'position': 26,
},
]
def get_settings_defaults(self) -> List[dict]: def get_settings_defaults(self) -> List[dict]:
"""Settings this plugin owns. """Settings this plugin owns.
@@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin):
'description': 'Hours without a collector report before a PC ' 'description': 'Hours without a collector report before a PC '
'is listed as not reporting on the dashboard.', 'is listed as not reporting on the dashboard.',
}, },
{
'key': 'computers_routableranges',
'value': '',
'valuetype': 'string',
'category': 'computers',
'description': 'Comma-separated CIDRs naming this site\'s '
'routable ranges, e.g. 10.20.0.0/23,10.21.4.0/26. '
'Stamped into the collector script this server '
'generates, so a bay with a controller NIC and a '
'corporate NIC reports the right one. Blank uses '
'the NIC carrying the default route, which needs '
'no knowledge of a site\'s addressing.',
},
{ {
'key': 'computers_machinelink_alerts', 'key': 'computers_machinelink_alerts',
'value': 'false', 'value': 'false',

View File

@@ -1223,6 +1223,9 @@ def list_reports():
'location': known.get('location'), 'location': known.get('location'),
'mapx': known.get('mapx'), 'mapx': known.get('mapx'),
'mapy': known.get('mapy'), 'mapy': known.get('mapy'),
# Coordinates are pixels of ONE level (ADR-017), so the level goes
# with them or the hover preview has nothing to draw on.
'levelid': known.get('levelid'),
'machinenumber': known.get('machinenumber'), 'machinenumber': known.get('machinenumber'),
'machineassetid': known.get('machineassetid'), 'machineassetid': known.get('machineassetid'),
'machinepluginid': known.get('machinepluginid'), 'machinepluginid': known.get('machinepluginid'),

View File

@@ -51,7 +51,7 @@
empty map would be worse than none. --> empty map would be worse than none. -->
<LocationMapTooltip <LocationMapTooltip
v-if="report.mapx != null && report.mapy != null" v-if="report.mapx != null && report.mapy != null"
:left="report.mapx" :top="report.mapy" :left="report.mapx" :top="report.mapy" :levelid="report.levelid"
:machineName="report.hostname" :machineName="report.hostname"
> >
<span class="map-pin" :title="report.location || 'On the floor plan'">&#9678;</span> <span class="map-pin" :title="report.location || 'On the floor plan'">&#9678;</span>

View File

@@ -167,6 +167,7 @@
v-if="machine.mapx != null && machine.mapy != null" v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx" :left="machine.mapx"
:top="machine.mapy" :top="machine.mapy"
:levelid="machine.levelid"
:machineName="machine.assetnumber" :machineName="machine.assetnumber"
> >
<span class="location-link">{{ machine.locationname || 'On Map' }}</span> <span class="location-link">{{ machine.locationname || 'On Map' }}</span>

View File

@@ -1042,7 +1042,7 @@ def _get_low_supplies_data():
'model': model_number, 'model': model_number,
'location': location_name, 'location': location_name,
'mapx': asset.mapx, 'mapx': asset.mapx,
'levelid': asset.levelid, 'levelid': asset.levelid,
'mapy': asset.mapy, 'mapy': asset.mapy,
'supplies': annotated 'supplies': annotated
}) })
@@ -1479,6 +1479,10 @@ def dashboard_supplies():
# card, it just has nothing to preview. # card, it just has nothing to preview.
'mapx': printer.get('mapx'), 'mapx': printer.get('mapx'),
'mapy': printer.get('mapy'), 'mapy': printer.get('mapy'),
# The level those pixels belong to (ADR-017). Without it the hover
# preview cannot draw the marker and says so, which is what the
# dashboard card and the toner report were both doing.
'levelid': printer.get('levelid'),
'iscritical': any(s['status'] == 'critical' for s in depleted), 'iscritical': any(s['status'] == 'critical' for s in depleted),
'supplies': [{ 'supplies': [{
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')), 'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),

View File

@@ -169,6 +169,7 @@
v-if="printer.mapx != null && printer.mapy != null" v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx" :left="printer.mapx"
:top="printer.mapy" :top="printer.mapy"
:levelid="printer.levelid"
:machineName="printer.name || printer.assetnumber" :machineName="printer.name || printer.assetnumber"
> >
<span class="location-link">View on Map</span> <span class="location-link">View on Map</span>

View File

@@ -43,14 +43,13 @@
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']" :class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
> >
<template v-if="page[pos - 1]"> <template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div> <div class="csf-name">{{ labelName(page[pos - 1]) }}</div>
<div class="qr-container"> <div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" /> <img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner"> <div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div> <div v-if="fqdnFor(page[pos - 1])" class="info-row">{{ fqdnFor(page[pos - 1]) }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div> <div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div> </div>
</div> </div>
@@ -67,6 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes' import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget' import { buildQrUrl } from '@/utils/qrTarget'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const printers = ref([]) const printers = ref([])
const selectedPrinters = ref([]) const selectedPrinters = ref([])
@@ -91,6 +91,7 @@ const pages = computed(() => {
onMounted(async () => { onMounted(async () => {
try { try {
hostnameTemplate.value = await getPrinterHostnameTemplate()
// listAll: perpage is clamped to 100, and a batch sheet must cover every // listAll: perpage is clamped to 100, and a batch sheet must cover every
// printer, not the first page of them. // printer, not the first page of them.
printers.value = await printersApi.listAll() printers.value = await printersApi.listAll()
@@ -129,6 +130,29 @@ async function generateQRCodes() {
qrImages.value = next qrImages.value = next
} }
// The printer's FQDN. A stored hostname wins; otherwise the site builds one
// from the IP through the printer_hostname_template setting (ADR-015), which is
// how PrinterForm and the toner report derive it.
const hostnameTemplate = ref('')
function fqdnFor(item) {
const stored = item?.printer?.hostname
if (stored) return stored
const ip = getIp(item)
if (!ip || !hostnameTemplate.value) return ''
return hostnameTemplate.value.replace('{ip}', ip.replace(/\./g, '-'))
}
// What goes on the label's prominent top line: the printer's NAME, e.g.
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
// queue name when one is set, otherwise the asset's name, and in practice most
// printers carry it as the assetnumber and nothing else, so that is the last
// fallback rather than a blank label. This is a name, not an identifier line:
// the label deliberately carries no separate "Asset #".
function labelName(item) {
return item?.printer?.windowsname || item?.name || item?.assetnumber || ''
}
function displayName(printer) { function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}` return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
} }
@@ -286,7 +310,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; } .info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; } .info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; } .info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; } .csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
.empty-label { color: #999; font-size: 14px; } .empty-label { color: #999; font-size: 14px; }
@media print { @media print {

View File

@@ -24,14 +24,13 @@
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']" :class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
> >
<template v-if="pos === parseInt(position)"> <template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div> <div class="csf-name">{{ labelName }}</div>
<div class="qr-container"> <div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" /> <img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner"> <div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div> <div v-if="fqdn" class="info-row">{{ fqdn }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div> <div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div> </div>
</div> </div>
@@ -49,6 +48,7 @@ import { useRoute } from 'vue-router'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes' import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget' import { buildQrUrl } from '@/utils/qrTarget'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
@@ -58,6 +58,18 @@ const position = ref('1')
// in print output, images print every time. // in print output, images print every time.
const qrImage = ref('') const qrImage = ref('')
// What goes on the label's prominent top line: the printer's NAME, e.g.
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
// queue name when one is set, otherwise the asset's name, and in practice most
// printers carry it as the assetnumber and nothing else, so that is the last
// fallback rather than a blank label. This is a name, not an identifier line:
// the label deliberately carries no separate "Asset #".
const labelName = computed(() =>
printer.value?.printer?.windowsname
|| printer.value?.name
|| printer.value?.assetnumber
|| '')
const ipAddress = computed(() => { const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API) // Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
@@ -67,8 +79,21 @@ const ipAddress = computed(() => {
return primary?.ipaddress || primary?.address || null return primary?.ipaddress || primary?.address || null
}) })
// The printer's FQDN. A stored hostname wins; otherwise the site builds one from
// the IP through the printer_hostname_template setting (ADR-015), the same way
// PrinterForm and the toner report derive it.
const hostnameTemplate = ref('')
const fqdn = computed(() => {
const stored = printer.value?.printer?.hostname
if (stored) return stored
if (!ipAddress.value || !hostnameTemplate.value) return ''
return hostnameTemplate.value.replace('{ip}', ipAddress.value.replace(/\./g, '-'))
})
onMounted(async () => { onMounted(async () => {
try { try {
hostnameTemplate.value = await getPrinterHostnameTemplate()
const response = await printersApi.get(route.params.id) const response = await printersApi.get(route.params.id)
printer.value = response.data.data printer.value = response.data.data
} catch (error) { } catch (error) {
@@ -169,7 +194,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; } .info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; } .info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; } .info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; } .csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
@media print { @media print {
/* Force rendered images/colors to print even when "Background graphics" is /* Force rendered images/colors to print even when "Background graphics" is

View File

@@ -1,378 +1,379 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h1>Toner Report</h1> <h1>Toner Report</h1>
<div class="header-actions"> <div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button> <button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report" <EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" /> :columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link> <router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div> </div>
</div> </div>
<div v-if="loading" class="loading">Loading supply data...</div> <div v-if="loading" class="loading">Loading supply data...</div>
<div v-else-if="error" class="card"> <div v-else-if="error" class="card">
<p class="text-danger">{{ error }}</p> <p class="text-danger">{{ error }}</p>
</div> </div>
<template v-else> <template v-else>
<!-- Summary Bar --> <!-- Summary Bar -->
<div class="summary-bar"> <div class="summary-bar">
<div class="summary-stat card"> <div class="summary-stat card">
<div class="stat-value">{{ summary.total_checked }}</div> <div class="stat-value">{{ summary.total_checked }}</div>
<div class="stat-label">Printers Checked</div> <div class="stat-label">Printers Checked</div>
</div> </div>
<div class="summary-stat card stat-low"> <div class="summary-stat card stat-low">
<div class="stat-value">{{ summary.low }}</div> <div class="stat-value">{{ summary.low }}</div>
<div class="stat-label">Low Supply</div> <div class="stat-label">Low Supply</div>
</div> </div>
<div class="summary-stat card stat-critical"> <div class="summary-stat card stat-critical">
<div class="stat-value">{{ summary.critical }}</div> <div class="stat-value">{{ summary.critical }}</div>
<div class="stat-label">Critical Supply</div> <div class="stat-label">Critical Supply</div>
</div> </div>
</div> </div>
<!-- Filter Buttons --> <!-- Filter Buttons -->
<div class="filters"> <div class="filters">
<button <button
v-for="f in filterOptions" v-for="f in filterOptions"
:key="f.value" :key="f.value"
class="btn" class="btn"
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'" :class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
@click="filter = f.value" @click="filter = f.value"
> >
{{ f.label }} {{ f.label }}
</button> </button>
</div> </div>
<!-- Printers Table --> <!-- Printers Table -->
<div class="card"> <div class="card">
<div class="table-container"> <div class="table-container">
<table v-if="filteredPrinters.length"> <table v-if="filteredPrinters.length">
<thead> <thead>
<tr> <tr>
<th>Printer Name</th> <th>Printer Name</th>
<th>Hostname</th> <th>Hostname</th>
<th>Supplies</th> <th>Supplies</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="printer in filteredPrinters" :key="printer.printerid"> <tr v-for="printer in filteredPrinters" :key="printer.printerid">
<td> <td>
<!-- Coordinates give the name the same floor-plan preview the <!-- Coordinates give the name the same floor-plan preview the
printer's own page uses; it replaces the Location column. --> printer's own page uses; it replaces the Location column. -->
<LocationMapTooltip <LocationMapTooltip
v-if="printer.mapx != null && printer.mapy != null" v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx" :left="printer.mapx"
:top="printer.mapy" :top="printer.mapy"
:machineName="printer.printername || printer.assetnumber" :levelid="printer.levelid"
> :machineName="printer.printername || printer.assetnumber"
<router-link :to="`/printers/${printer.printerid}`"> >
{{ printer.printername || 'Unknown' }} <router-link :to="`/printers/${printer.printerid}`">
</router-link> {{ printer.printername || 'Unknown' }}
</LocationMapTooltip> </router-link>
<router-link v-else :to="`/printers/${printer.printerid}`"> </LocationMapTooltip>
{{ printer.printername || 'Unknown' }} <router-link v-else :to="`/printers/${printer.printerid}`">
</router-link> {{ printer.printername || 'Unknown' }}
</td> </router-link>
<td> </td>
<!-- Opens the printer's own web page. New tab: the report is a <td>
worklist, and losing your place in it to visit one printer <!-- Opens the printer's own web page. New tab: the report is a
means finding your row again. --> worklist, and losing your place in it to visit one printer
<a means finding your row again. -->
v-if="fqdnFor(printer)" <a
:href="`http://${fqdnFor(printer)}`" v-if="fqdnFor(printer)"
target="_blank" :href="`http://${fqdnFor(printer)}`"
rel="noopener noreferrer" target="_blank"
:title="printer.ipaddress" rel="noopener noreferrer"
>{{ fqdnFor(printer) }}</a> :title="printer.ipaddress"
<span v-else>{{ printer.ipaddress || '-' }}</span> >{{ fqdnFor(printer) }}</a>
</td> <span v-else>{{ printer.ipaddress || '-' }}</span>
<td class="supplies-cell"> </td>
<div <td class="supplies-cell">
v-for="(supply, idx) in printer.supplies" <div
:key="idx" v-for="(supply, idx) in printer.supplies"
class="supply-row" :key="idx"
> class="supply-row"
<span class="supply-name">{{ supply.name }}</span> >
<div class="supply-bar-track"> <span class="supply-name">{{ supply.name }}</span>
<div <div class="supply-bar-track">
class="supply-bar-fill" <div
:class="'supply-' + supply.status" class="supply-bar-fill"
:style="{ width: Math.max(supply.level, 2) + '%' }" :class="'supply-' + supply.status"
></div> :style="{ width: Math.max(supply.level, 2) + '%' }"
</div> ></div>
<span class="supply-level" :class="'supply-text-' + supply.status"> </div>
{{ supply.level }}% <span class="supply-level" :class="'supply-text-' + supply.status">
</span> {{ supply.level }}%
<span class="supply-parts"> </span>
<span <span class="supply-parts">
v-for="part in supply.partnumbers" <span
:key="part.partnumber" v-for="part in supply.partnumbers"
class="part-chip" :key="part.partnumber"
:title="partTooltip(part)" class="part-chip"
>{{ part.partnumber }}</span> :title="partTooltip(part)"
<span v-if="!supply.partnumbers || !supply.partnumbers.length" >{{ part.partnumber }}</span>
class="part-none" title="No part number on file for this model and color">-</span> <span v-if="!supply.partnumbers || !supply.partnumbers.length"
</span> class="part-none" title="No part number on file for this model and color">-</span>
</div> </span>
</td> </div>
</tr> </td>
</tbody> </tr>
</table> </tbody>
<p v-else class="empty-state">No printers match the selected filter.</p> </table>
</div> <p v-else class="empty-state">No printers match the selected filter.</p>
</div> </div>
</template> </div>
</div> </template>
</template> </div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue' <script setup>
import { printersApi } from '@/api' import { ref, computed, onMounted } from 'vue'
import EmailReportButton from '@/components/EmailReportButton.vue' import { printersApi } from '@/api'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue' import EmailReportButton from '@/components/EmailReportButton.vue'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings' import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const emailColumns = [
{ key: 'printer', label: 'Printer' }, const emailColumns = [
{ key: 'hostname', label: 'Hostname' }, { key: 'printer', label: 'Printer' },
{ key: 'supply', label: 'Supply' }, { key: 'hostname', label: 'Hostname' },
{ key: 'partnumber', label: 'Part Number' }, { key: 'supply', label: 'Supply' },
{ key: 'level', label: 'Level' }, { key: 'partnumber', label: 'Part Number' },
{ key: 'status', label: 'Status' }, { key: 'level', label: 'Level' },
] { key: 'status', label: 'Status' },
]
const loading = ref(true)
const error = ref(null) const loading = ref(true)
const printers = ref([]) const error = ref(null)
const summary = ref({ total_checked: 0, low: 0, critical: 0 }) const printers = ref([])
const filter = ref('all') const summary = ref({ total_checked: 0, low: 0, critical: 0 })
const filter = ref('all')
const filterOptions = [
{ label: 'All', value: 'all' }, const filterOptions = [
{ label: 'Critical', value: 'critical' }, { label: 'All', value: 'all' },
{ label: 'Low', value: 'low' } { label: 'Critical', value: 'critical' },
] { label: 'Low', value: 'low' }
]
// Printers have no stored FQDN; the site builds one from the IP via the
// printer_hostname_template setting, the same way PrinterForm does. // Printers have no stored FQDN; the site builds one from the IP via the
const hostnameTemplate = ref('') // printer_hostname_template setting, the same way PrinterForm does.
const hostnameTemplate = ref('')
function fqdnFor(printer) {
if (!printer.ipaddress || !hostnameTemplate.value) return '' function fqdnFor(printer) {
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-')) if (!printer.ipaddress || !hostnameTemplate.value) return ''
} return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
}
// A model can list several capacity tiers for one color, so the chip shows the
// part number and the tooltip says which one it is. // A model can list several capacity tiers for one color, so the chip shows the
function partTooltip(part) { // part number and the tooltip says which one it is.
const bits = [part.marketingname, part.capacitytier] function partTooltip(part) {
if (part.pageyield) bits.push(part.pageyield + ' pages') const bits = [part.marketingname, part.capacitytier]
return bits.filter(Boolean).join(' - ') if (part.pageyield) bits.push(part.pageyield + ' pages')
} return bits.filter(Boolean).join(' - ')
}
// One flat row per part number so an ordering list is copy-pasteable; supplies
// with no part on file still get a row. // One flat row per part number so an ordering list is copy-pasteable; supplies
function reportRows() { // with no part on file still get a row.
const rows = [] function reportRows() {
for (const printer of filteredPrinters.value) { const rows = []
for (const supply of printer.supplies || []) { for (const printer of filteredPrinters.value) {
const parts = supply.partnumbers && supply.partnumbers.length for (const supply of printer.supplies || []) {
? supply.partnumbers.map(p => p.partnumber) const parts = supply.partnumbers && supply.partnumbers.length
: [''] ? supply.partnumbers.map(p => p.partnumber)
for (const partnumber of parts) { : ['']
rows.push({ for (const partnumber of parts) {
printer: printer.printername || '', rows.push({
hostname: fqdnFor(printer) || printer.ipaddress || '', printer: printer.printername || '',
supply: supply.name || '', hostname: fqdnFor(printer) || printer.ipaddress || '',
partnumber: partnumber, supply: supply.name || '',
level: supply.level, partnumber: partnumber,
status: supply.status || '', level: supply.level,
}) status: supply.status || '',
} })
} }
} }
return rows }
} return rows
}
const filteredPrinters = computed(() => {
if (filter.value === 'all') return printers.value const filteredPrinters = computed(() => {
return printers.value.filter(p => if (filter.value === 'all') return printers.value
p.supplies.some(s => s.status === filter.value) return printers.value.filter(p =>
) p.supplies.some(s => s.status === filter.value)
}) )
})
// Emailed table, honoring the active filter.
const emailRows = computed(() => // Emailed table, honoring the active filter.
reportRows().map(row => ({ ...row, level: row.level + '%' })) const emailRows = computed(() =>
) reportRows().map(row => ({ ...row, level: row.level + '%' }))
)
function exportCSV() {
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"` function exportCSV() {
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status'] const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
const rows = [header] const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
for (const row of reportRows()) { const rows = [header]
rows.push(header.map(key => row[key])) for (const row of reportRows()) {
} rows.push(header.map(key => row[key]))
const csv = rows.map(row => row.map(quote).join(',')).join('\n') }
const link = document.createElement('a') const csv = rows.map(row => row.map(quote).join(',')).join('\n')
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })) const link = document.createElement('a')
link.download = 'toner_report.csv' link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
link.click() link.download = 'toner_report.csv'
URL.revokeObjectURL(link.href) link.click()
} URL.revokeObjectURL(link.href)
}
onMounted(async () => {
try { onMounted(async () => {
hostnameTemplate.value = await getPrinterHostnameTemplate() try {
const response = await printersApi.lowSupplies() hostnameTemplate.value = await getPrinterHostnameTemplate()
const data = response.data.data const response = await printersApi.lowSupplies()
printers.value = data.printers || [] const data = response.data.data
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 } printers.value = data.printers || []
} catch (err) { summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
console.error('Error loading toner report:', err) } catch (err) {
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.' console.error('Error loading toner report:', err)
} finally { error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
loading.value = false } finally {
} loading.value = false
}) }
</script> })
</script>
<style scoped>
.summary-bar { <style scoped>
display: flex; .summary-bar {
gap: 1.5rem; display: flex;
margin-bottom: 1.5rem; gap: 1.5rem;
} margin-bottom: 1.5rem;
}
.summary-stat {
flex: 1; .summary-stat {
text-align: center; flex: 1;
padding: 1.25rem; text-align: center;
} padding: 1.25rem;
}
.stat-value {
font-size: 2rem; .stat-value {
font-weight: 700; font-size: 2rem;
color: var(--text); font-weight: 700;
} color: var(--text);
}
.stat-label {
color: var(--text-light); .stat-label {
margin-top: 0.25rem; color: var(--text-light);
} margin-top: 0.25rem;
}
.stat-low .stat-value {
color: var(--warning); .stat-low .stat-value {
} color: var(--warning);
}
.stat-critical .stat-value {
color: var(--danger); .stat-critical .stat-value {
} color: var(--danger);
}
/* One grid for the whole cell, with each supply row contributing its cells
directly (display: contents). Cartridge names were being ellipsised inside a /* One grid for the whole cell, with each supply row contributing its cells
fixed 120px column, which hid the very thing being reordered; a max-content directly (display: contents). Cartridge names were being ellipsised inside a
column sizes to the longest name instead, and the columns still line up fixed 120px column, which hid the very thing being reordered; a max-content
across the rows of one printer. */ column sizes to the longest name instead, and the columns still line up
.supplies-cell { across the rows of one printer. */
min-width: 460px; .supplies-cell {
display: grid; min-width: 460px;
grid-template-columns: max-content minmax(80px, 1fr) auto max-content; display: grid;
column-gap: 0.5rem; grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
row-gap: 0.35rem; column-gap: 0.5rem;
align-items: center; row-gap: 0.35rem;
} align-items: center;
}
.supply-row {
display: contents; .supply-row {
} display: contents;
}
.supply-name {
font-size: 0.85rem; .supply-name {
color: var(--text-light); font-size: 0.85rem;
white-space: nowrap; color: var(--text-light);
} white-space: nowrap;
}
.supply-bar-track {
height: 8px; .supply-bar-track {
background: var(--border); height: 8px;
border-radius: 4px; background: var(--border);
overflow: hidden; border-radius: 4px;
} overflow: hidden;
}
.supply-bar-fill {
height: 100%; .supply-bar-fill {
border-radius: 4px; height: 100%;
transition: width 0.3s ease; border-radius: 4px;
} transition: width 0.3s ease;
}
.supply-ok {
background: var(--success); .supply-ok {
} background: var(--success);
}
.supply-low {
background: var(--warning); .supply-low {
} background: var(--warning);
}
.supply-critical {
background: var(--danger); .supply-critical {
} background: var(--danger);
}
.supply-level {
min-width: 40px; .supply-level {
text-align: right; min-width: 40px;
font-size: 0.85rem; text-align: right;
font-weight: 600; font-size: 0.85rem;
} font-weight: 600;
}
.supply-parts {
display: flex; .supply-parts {
flex-wrap: wrap; display: flex;
gap: 0.25rem; flex-wrap: wrap;
} gap: 0.25rem;
}
.part-chip {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; .part-chip {
font-size: 0.75rem; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
padding: 0.05rem 0.35rem; font-size: 0.75rem;
border: 1px solid var(--border); padding: 0.05rem 0.35rem;
border-radius: 3px; border: 1px solid var(--border);
color: var(--text); border-radius: 3px;
white-space: nowrap; color: var(--text);
} white-space: nowrap;
}
.part-none {
font-size: 0.85rem; .part-none {
color: var(--text-light); font-size: 0.85rem;
} color: var(--text-light);
}
.supply-text-ok {
color: var(--success); .supply-text-ok {
} color: var(--success);
}
.supply-text-low {
color: var(--warning); .supply-text-low {
} color: var(--warning);
}
.supply-text-critical {
color: var(--danger); .supply-text-critical {
} color: var(--danger);
}
.empty-state {
text-align: center; .empty-state {
padding: 2rem; text-align: center;
color: var(--text-light); padding: 2rem;
} color: var(--text-light);
}
.text-danger {
color: var(--danger); .text-danger {
} color: var(--danger);
}
.header-actions {
display: flex; .header-actions {
gap: 0.5rem; display: flex;
} gap: 0.5rem;
</style> }
</style>

View File

@@ -0,0 +1,495 @@
// EventSaver - shopfloor event-advert screensaver.
// Two source modes, set in EventSaver.ini next to the .scr (no recompile):
// url=https://.../shopdb/api/slides/feed?surface=shopfloor -> pull from shopdb over
// HTTP, cache images locally, rotate the cache (no file share needed).
// folder=\\server\share\path -> read an SMB/local folder.
// url wins if both set. Strict order + per-slide seconds via order.txt (or the
// API's slides[].seconds). Cache survives a network blip (keeps last-good).
//
// Screensaver arg contract:
// /s show (fullscreen)
// /p <hwnd> preview (we no-op - keeps Windows happy)
// /c config (points user at the ini)
//
// Build (in-box .NET Framework, no SDK):
// C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
// /target:winexe /out:EventSaver.scr ^
// /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll,System.Web.Extensions.dll ^
// EventSaver.cs
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Web.Script.Serialization;
using System.Windows.Forms;
namespace EventSaver
{
internal static class Program
{
[STAThread]
private static void Main(string[] args)
{
string mode = "/s";
if (args.Length > 0) mode = args[0].ToLowerInvariant().Trim();
// strip a trailing ":hwnd" some callers append (e.g. /p:12345)
if (mode.StartsWith("/p")) mode = "/p";
if (mode.StartsWith("/c")) mode = "/c";
if (mode.StartsWith("/s")) mode = "/s";
if (mode == "/test")
{
// headless self-test: print the resolved playlist order and exit.
// lets CI / a display-less VM verify folder-read + order.txt + sort.
// winexe has no console in session 0, so write results to a
// file next to the exe (and Console too, for interactive runs).
Config tc = Config.Load();
string tf = tc.SourceFolder(true); // http mode: sync cache first
List<Slide> pl = Playlist.Build(tf, tc.Shuffle);
List<string> lines = new List<string>();
lines.Add((tc.Url.Length > 0 ? "url=" + tc.Url + " cache=" : "folder=") + tf);
lines.Add("interval=" + tc.IntervalSeconds + " shuffle=" + tc.Shuffle);
lines.Add("count=" + pl.Count);
for (int i = 0; i < pl.Count; i++)
lines.Add(string.Format("{0,2}: {1} (secs={2})", i + 1, Path.GetFileName(pl[i].Path), pl[i].Seconds));
foreach (string l in lines) Console.WriteLine(l);
try
{
string outDir = Path.GetDirectoryName(Application.ExecutablePath);
File.WriteAllLines(Path.Combine(outDir, "eventsaver-test-out.txt"), lines.ToArray());
}
catch { }
return;
}
if (mode == "/render")
{
// headless render check: draw the first slide onto a 1280x720
// black canvas with the same fit logic as OnPaint, save a PNG.
// Lets a display-less VM prove decode + letterbox actually work.
Config rc = Config.Load();
List<Slide> rpl = Playlist.Build(rc.SourceFolder(true), rc.Shuffle);
string outPng = args.Length > 1 ? args[1] : Path.Combine(
Path.GetDirectoryName(Application.ExecutablePath), "eventsaver-render.png");
using (Bitmap canvas = new Bitmap(1280, 720))
using (Graphics g = Graphics.FromImage(canvas))
{
g.Clear(Color.Black);
if (rpl.Count > 0)
{
using (FileStream fs = new FileStream(rpl[0].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (Image img = Image.FromStream(fs))
{
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
Rectangle r = SaverForm.FitZoomPublic(img.Size, canvas.Size);
g.DrawImage(img, r);
}
}
canvas.Save(outPng, System.Drawing.Imaging.ImageFormat.Png);
}
return;
}
if (mode == "/c")
{
MessageBox.Show(
"Edit EventSaver.ini next to EventSaver.scr to set the image folder, interval, and order.",
"EventSaver", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (mode == "/p")
{
// preview pane - do nothing, exit clean
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Config cfg = Config.Load();
// one form per screen: primary shows slideshow, others stay black
List<Form> forms = new List<Form>();
Screen primary = Screen.PrimaryScreen;
foreach (Screen scr in Screen.AllScreens)
{
bool isPrimary = scr.Equals(primary);
SaverForm f = new SaverForm(scr, isPrimary ? cfg : null);
forms.Add(f);
}
foreach (Form f in forms) f.Show();
// Keep the monitor awake while the screensaver shows, so a shorter
// monitor-sleep policy can't blank the ads out from under us. Held
// for the life of the message loop, released on exit.
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
Application.Run(forms[0]);
SetThreadExecutionState(ES_CONTINUOUS);
}
[DllImport("kernel32.dll")]
private static extern uint SetThreadExecutionState(uint esFlags);
private const uint ES_CONTINUOUS = 0x80000000;
private const uint ES_DISPLAY_REQUIRED = 0x00000002;
private const uint ES_SYSTEM_REQUIRED = 0x00000001;
}
// ------------------------------------------------------------------ config
internal sealed class Config
{
// No default: a share path belongs to a site, not to this program
// (ADR-015). Folder mode is the fallback for a site with no HTTP
// reach to ShopDB, and it must name its own path in EventSaver.ini.
public string Folder = "";
public string Url = ""; // set -> HTTP mode (pull from shopdb)
public string CacheDir = ""; // local cache for HTTP mode (computed)
public int IntervalSeconds = 10;
public bool Shuffle = false;
public int FadeMs = 600;
public static Config Load()
{
Config c = new Config();
try
{
string dir = Path.GetDirectoryName(Application.ExecutablePath);
string ini = Path.Combine(dir, "EventSaver.ini");
if (!File.Exists(ini)) return c;
foreach (string raw in File.ReadAllLines(ini))
{
string line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
int eq = line.IndexOf('=');
if (eq <= 0) continue;
string key = line.Substring(0, eq).Trim().ToLowerInvariant();
string val = line.Substring(eq + 1).Trim();
if (key == "url" && val.Length > 0) c.Url = val;
else if (key == "folder" && val.Length > 0) c.Folder = val;
else if (key == "interval") { int n; if (int.TryParse(val, out n) && n > 0) c.IntervalSeconds = n; }
else if (key == "shuffle") c.Shuffle = (val == "1" || val.ToLowerInvariant() == "true");
else if (key == "fadems") { int n; if (int.TryParse(val, out n) && n >= 0) c.FadeMs = n; }
}
}
catch { /* bad ini - fall back to defaults */ }
return c;
}
// Folder the playlist reads: the local cache in HTTP mode (synced first
// when sync=true), else the configured share/folder. HTTP failures leave
// the last-good cache in place.
public string SourceFolder(bool sync)
{
if (Url.Length == 0) return Folder;
if (CacheDir.Length == 0)
CacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EventSaver", "cache");
if (sync) { try { HttpSync.Sync(Url, CacheDir); } catch { } }
return CacheDir;
}
}
// ------------------------------------------------------------- http sync
// Pull the slide list from shopdb (/api/slides/feed), download images into a
// local cache dir, and write order.txt there so the normal Playlist logic
// reads the cache exactly like a folder. Idempotent: only downloads images
// not already cached, prunes ones no longer listed, keeps last-good on error.
internal static class HttpSync
{
public static void Sync(string apiUrl, string cacheDir)
{
try { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; } catch { }
if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir);
string json;
using (WebClient wc = new WebClient()) { wc.Encoding = Encoding.UTF8; json = wc.DownloadString(apiUrl); }
JavaScriptSerializer js = new JavaScriptSerializer();
IDictionary<string, object> root = js.DeserializeObject(json) as IDictionary<string, object>;
if (root == null) return;
object ok;
if (!root.TryGetValue("success", out ok) || !(ok is bool) || !((bool)ok)) return;
string basepath = root.ContainsKey("basepath") ? Convert.ToString(root["basepath"]) : "";
object slidesObj;
if (!root.TryGetValue("slides", out slidesObj)) return;
object[] arr = slidesObj as object[];
if (arr == null) return;
Uri apiUri = new Uri(apiUrl);
// The feed's basepath is host-absolute (/api/slides/img/...) and omits
// the app's mount (e.g. /shopdb) - the web client adds it via withBase,
// so we must too, else images resolve to the host root and 404. Derive
// the mount from the feed URL's path (everything before "/api/").
string mount = "";
int apiIdx = apiUri.AbsolutePath.IndexOf("/api/", StringComparison.OrdinalIgnoreCase);
if (apiIdx > 0) mount = apiUri.AbsolutePath.Substring(0, apiIdx);
HashSet<string> keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
List<string> order = new List<string>();
order.Add("# generated by EventSaver from " + apiUrl);
foreach (object o in arr)
{
IDictionary<string, object> s = o as IDictionary<string, object>;
if (s == null) continue;
string fn = s.ContainsKey("filename") ? Convert.ToString(s["filename"]) : null;
if (string.IsNullOrEmpty(fn)) continue;
string safe = Path.GetFileName(fn); // strip any path component
if (safe.Length == 0) continue;
int secs = 0;
if (s.ContainsKey("seconds")) { int n; if (int.TryParse(Convert.ToString(s["seconds"]), out n) && n > 0) secs = n; }
string local = Path.Combine(cacheDir, safe);
if (!File.Exists(local))
{
try
{
// Host-absolute basepath -> prepend the mount; a full URL passes through.
string imgRef = basepath.StartsWith("/") ? mount + basepath : basepath;
Uri img = new Uri(apiUri, imgRef + Uri.EscapeDataString(safe));
using (WebClient wc = new WebClient()) { byte[] b = wc.DownloadData(img); File.WriteAllBytes(local, b); }
}
catch { continue; } // couldn't fetch this one - skip it this round
}
keep.Add(safe);
order.Add(secs > 0 ? safe + "|" + secs : safe);
}
try { File.WriteAllLines(Path.Combine(cacheDir, "order.txt"), order.ToArray()); } catch { }
// prune cache images no longer referenced
foreach (string f in Directory.GetFiles(cacheDir))
{
string n = Path.GetFileName(f);
if (string.Equals(n, "order.txt", StringComparison.OrdinalIgnoreCase)) continue;
if (!keep.Contains(n)) { try { File.Delete(f); } catch { } }
}
}
}
// ------------------------------------------------------------- playlist
// Builds ordered file list. order.txt wins (strict sequence, one name per
// line, optional "name|seconds" per-slide duration). Else sort by name.
internal sealed class Slide
{
public string Path;
public int Seconds; // 0 = use default interval
}
internal static class Playlist
{
private static readonly string[] Exts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
public static List<Slide> Build(string folder, bool shuffle)
{
List<Slide> list = new List<Slide>();
try
{
if (!Directory.Exists(folder)) return list;
string orderFile = Path.Combine(folder, "order.txt");
if (File.Exists(orderFile))
{
// strict sequence from order.txt
foreach (string raw in File.ReadAllLines(orderFile))
{
string line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
int secs = 0;
string name = line;
int bar = line.IndexOf('|');
if (bar > 0)
{
name = line.Substring(0, bar).Trim();
int n; if (int.TryParse(line.Substring(bar + 1).Trim(), out n) && n > 0) secs = n;
}
string full = Path.Combine(folder, name);
if (IsImage(full) && File.Exists(full))
list.Add(new Slide { Path = full, Seconds = secs });
}
return list; // order.txt is authoritative - do not append extras
}
// no order.txt - all images, sorted by filename
List<string> files = new List<string>();
foreach (string f in Directory.GetFiles(folder))
if (IsImage(f)) files.Add(f);
files.Sort(StringComparer.OrdinalIgnoreCase);
if (shuffle) Shuf(files);
foreach (string f in files) list.Add(new Slide { Path = f, Seconds = 0 });
}
catch { /* share unreachable - return what we have (maybe empty) */ }
return list;
}
private static bool IsImage(string path)
{
string e = Path.GetExtension(path).ToLowerInvariant();
foreach (string x in Exts) if (x == e) return true;
return false;
}
// deterministic-enough shuffle; screensaver so exact randomness irrelevant
private static void Shuf(List<string> l)
{
Random r = new Random();
for (int i = l.Count - 1; i > 0; i--)
{
int j = r.Next(i + 1);
string t = l[i]; l[i] = l[j]; l[j] = t;
}
}
}
// ------------------------------------------------------------- saver form
internal sealed class SaverForm : Form
{
private readonly Config cfg; // null on non-primary screens (black only)
private readonly Timer timer;
private List<Slide> slides = new List<Slide>();
private int idx = -1;
private Image current;
private Point lastMouse = Point.Empty;
private bool mouseSeen = false;
private DateTime lastScan = DateTime.MinValue;
public SaverForm(Screen screen, Config c)
{
cfg = c;
FormBorderStyle = FormBorderStyle.None;
Bounds = screen.Bounds;
StartPosition = FormStartPosition.Manual;
BackColor = Color.Black;
TopMost = true;
ShowInTaskbar = false;
DoubleBuffered = true;
Cursor.Hide();
KeyPreview = true;
// Left/Right step through the slides by hand; ANY other key still
// wakes the machine, which is what a screensaver must do. Without
// that exception an operator tapping an arrow to get back to work
// would be stuck watching slides.
KeyDown += (s, e) =>
{
if (e.KeyCode == Keys.Left) { e.Handled = true; Step(-1); return; }
if (e.KeyCode == Keys.Right) { e.Handled = true; Step(1); return; }
Quit();
};
MouseDown += (s, e) => Quit();
MouseMove += OnMove;
if (cfg != null)
{
cfg.SourceFolder(true); // HTTP mode: initial sync + set CacheDir
Rescan();
timer = new Timer();
timer.Interval = 1000; // tick every second; advance when slide's time is up
timer.Tick += OnTick;
timer.Start();
Advance(); // show first immediately
}
}
private int slideElapsed = 0;
private DateTime lastSync = DateTime.Now; // ctor already did the first sync
private void OnTick(object sender, EventArgs e)
{
// HTTP mode: re-pull from shopdb every 60s so manager edits propagate.
if (cfg.Url.Length > 0 && (DateTime.Now - lastSync).TotalSeconds >= 60)
{
lastSync = DateTime.Now;
try { HttpSync.Sync(cfg.Url, cfg.CacheDir); } catch { }
lastScan = DateTime.MinValue; // force the rescan below
}
// periodic rescan so edits appear without restarting the saver
if ((DateTime.Now - lastScan).TotalSeconds >= 30) Rescan();
slideElapsed++;
int want = (slides.Count > 0 && idx >= 0 && slides[idx].Seconds > 0)
? slides[idx].Seconds : cfg.IntervalSeconds;
if (slideElapsed >= want) Advance();
}
private void Rescan()
{
lastScan = DateTime.Now;
List<Slide> fresh = Playlist.Build(cfg.SourceFolder(false), cfg.Shuffle);
slides = fresh;
if (idx >= slides.Count) idx = -1;
}
private void Advance()
{
Step(1);
}
// delta of +1 is the timer advancing, -1 is the operator going back.
// Resets the dwell timer either way: stepping by hand and then having it
// move again a moment later, because the tick was nearly up, reads as
// the screensaver ignoring the keypress.
private void Step(int delta)
{
slideElapsed = 0;
if (slides.Count == 0) { SetImage(null); return; }
if (idx < 0) idx = (delta < 0) ? 0 : -1; // first Step lands on slide 0
idx = ((idx + delta) % slides.Count + slides.Count) % slides.Count;
try
{
// load without locking the file on the share
Image img;
using (FileStream fs = new FileStream(slides[idx].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
img = Image.FromStream(fs);
SetImage(img);
}
catch { SetImage(null); }
}
private void SetImage(Image img)
{
Image old = current;
current = img;
if (old != null) old.Dispose();
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.Clear(Color.Black);
if (current == null) return;
e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
Rectangle r = FitZoom(current.Size, ClientSize);
e.Graphics.DrawImage(current, r);
}
// test hook - same math as OnPaint, exposed for the /render self-check.
public static Rectangle FitZoomPublic(Size img, Size box) { return FitZoom(img, box); }
// scale image to fit while preserving aspect (letterbox)
private static Rectangle FitZoom(Size img, Size box)
{
if (img.Width == 0 || img.Height == 0) return new Rectangle(0, 0, box.Width, box.Height);
double s = Math.Min((double)box.Width / img.Width, (double)box.Height / img.Height);
int w = (int)(img.Width * s);
int h = (int)(img.Height * s);
return new Rectangle((box.Width - w) / 2, (box.Height - h) / 2, w, h);
}
private void OnMove(object sender, MouseEventArgs e)
{
// ignore the first synthetic move; require real movement to exit
if (!mouseSeen) { mouseSeen = true; lastMouse = e.Location; return; }
if (Math.Abs(e.X - lastMouse.X) > 8 || Math.Abs(e.Y - lastMouse.Y) > 8) Quit();
}
private void Quit()
{
try { Cursor.Show(); } catch { }
Application.Exit();
}
}
}

View File

@@ -0,0 +1,19 @@
# EventSaver config. Lives next to EventSaver.scr.
# Change these without recompiling. Screensaver re-reads on each launch.
# HTTP mode (recommended): pull slides from shopdb over HTTP, cache locally.
# No file share needed. Point at the shopdb slides feed (/api/slides/feed) for this
# surface. FIX THE BASE URL if the shopdb path differs on the live box.
url=https://shopdb.example.net/api/slides/feed?surface=shopfloor
# Folder mode (fallback): used only if url is blank. SMB/local path.
# folder=\\fileserver.example.net\share\tv\shopfloor
# Seconds per image (default when a slide has no per-slide time).
interval=10
# 1 = random order, 0 = ordered. Ignored when order.txt / API order is present.
shuffle=0
# Crossfade length in ms (0 = hard cut). Reserved - hard cut in v1.
fadems=600

View File

@@ -6,6 +6,7 @@
v-if="machine && hasPosition" v-if="machine && hasPosition"
:left="machine.mapx" :left="machine.mapx"
:top="machine.mapy" :top="machine.mapy"
:levelid="machine.levelid"
:machineName="machine.machinenumber || machine.name || ''" :machineName="machine.machinenumber || machine.name || ''"
> >
<span class="mmc-chip" :title="hoverTitle"> <span class="mmc-chip" :title="hoverTitle">

View File

@@ -169,21 +169,44 @@ fi
# whether all of them travel with their level, because reading them by eye is # whether all of them travel with their level, because reading them by eye is
# how the twentieth gets missed. # how the twentieth gets missed.
echo "==> Checking that emitted map positions carry their level (ADR-017)..." echo "==> Checking that emitted map positions carry their level (ADR-017)..."
POSITION_FILES=$(grep -rln "'mapx':" --include='*.py' shopdb/ plugins/ 2>/dev/null \ # PER OCCURRENCE, not per file. The file-level form passed a module that emitted
| grep -v '/tests\?/' || true) # 'mapx' six times and 'levelid' once, and two payloads shipped without a level:
# the toner report and the enforcement report, both feeding a hover preview that
# then said "no level". Every 'mapx' must have a 'levelid' in the same literal -
# the window is wide enough for a comment between them, and no wider.
MISSING_LEVEL="" MISSING_LEVEL=""
for candidate in $POSITION_FILES; do while IFS= read -r hit; do
if ! grep -q "'levelid'" "$candidate"; then [ -z "$hit" ] && continue
MISSING_LEVEL="$MISSING_LEVEL$candidate"$'\n' file=${hit%%:*}
line=${hit#*:}; line=${line%%:*}
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'levelid'"; then
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
fi fi
done done <<EOF
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
EOF
if [ -n "$MISSING_LEVEL" ]; then if [ -n "$MISSING_LEVEL" ]; then
echo "FAIL: these emit 'mapx' but never 'levelid' - a position with no level" echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
echo " cannot be rendered on the right drawing:" echo " level cannot be drawn on the right floor plan (ADR-017):"
echo "$MISSING_LEVEL" | sed 's/^/ /' echo "$MISSING_LEVEL" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1)) VIOLATIONS=$((VIOLATIONS + 1))
fi fi
# The same rule for the hover preview: binding coordinates into
# LocationMapTooltip without a level makes it report "no level" for every asset,
# which is exactly what shipped in 0.11.0 - all seven call sites missed it.
TOOLTIP_MISSING=""
for candidate in $(grep -rl "LocationMapTooltip" --include='*.vue' frontend/src plugins/ 2>/dev/null | grep -v plugins-staged || true); do
grep -q ':left=' "$candidate" || continue
grep -q ':levelid=' "$candidate" || TOOLTIP_MISSING="$TOOLTIP_MISSING$candidate"$'\n'
done
if [ -n "$TOOLTIP_MISSING" ]; then
echo "FAIL: these bind LocationMapTooltip coordinates without :levelid, so the"
echo " preview cannot know which drawing to use (ADR-017):"
echo "$TOOLTIP_MISSING" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1))
fi
# ENFORCING. It was report-only while the backlog was worked off, and the hit # ENFORCING. It was report-only while the backlog was worked off, and the hit
# count then did not move for weeks - a rule that only prints is read as no rule. # count then did not move for weeks - a rule that only prints is read as no rule.
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run. # Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.

View File

@@ -100,6 +100,22 @@ class Harness:
self.ids = IdMap(idmap_path or default) self.ids = IdMap(idmap_path or default)
self.source = Source() self.source = Source()
self.errors = [] self.errors = []
self._defaultlevelid = None
@property
def defaultlevelid(self):
"""The level imported map positions belong to (ADR-017).
The legacy schema predates levels: it has ONE floor plan, so every
mapleft/maptop it carries is a coordinate on this site's default level.
Importing them without a level produces markers the map refuses to draw
- it will not guess a drawing for coordinates that do not name one.
"""
if self._defaultlevelid is None:
from shopdb.core.models import MapLevel
level = MapLevel.default_level()
self._defaultlevelid = level.levelid if level else None
return self._defaultlevelid
def _silence_sql_logging(self): def _silence_sql_logging(self):
import logging import logging

View File

@@ -287,6 +287,7 @@ def stage_assets(h):
'serialnumber': (m['serialnumber'] or '').strip() or None, 'serialnumber': (m['serialnumber'] or '').strip() or None,
'businessunitid': h.ids.get('businessunit', m['businessunitid']), 'businessunitid': h.ids.get('businessunit', m['businessunitid']),
'mapx': m['mapleft'], 'mapy': m['maptop'], 'mapx': m['mapleft'], 'mapy': m['maptop'],
'levelid': h.defaultlevelid,
'notes': m['machinenotes'], 'notes': m['machinenotes'],
'dateadded': str(m['dateadded']) if m['dateadded'] else None, 'dateadded': str(m['dateadded']) if m['dateadded'] else None,
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None, 'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
@@ -345,6 +346,7 @@ def stage_printers(h):
'iscsf': _truthy_bit(p['iscsf']), 'iscsf': _truthy_bit(p['iscsf']),
'installpath': p['installpath'], 'pin': p['printerpin'], 'installpath': p['installpath'], 'pin': p['printerpin'],
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'], 'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
'levelid': h.defaultlevelid,
'modelnumberid': h.ids.get('model', p['modelid']), 'modelnumberid': h.ids.get('model', p['modelid']),
'locationid': h.ids.get('location', p['machineid']), 'locationid': h.ids.get('location', p['machineid']),
} }
@@ -386,7 +388,8 @@ def stage_metrology(h):
'name': (f"{pcname} {toolname}").strip() or toolname, 'name': (f"{pcname} {toolname}").strip() or toolname,
'measuringtooltypeid': typeid, 'measuringtooltypeid': typeid,
'businessunitid': h.ids.get('businessunit', m['businessunitid']), 'businessunitid': h.ids.get('businessunit', m['businessunitid']),
'mapx': m['mapleft'], 'mapy': m['maptop']}) 'mapx': m['mapleft'], 'mapy': m['maptop'],
'levelid': h.defaultlevelid})
if status not in (200, 201): if status not in (200, 201):
continue continue
tool_assetid = _id_of(data, 'assetid') tool_assetid = _id_of(data, 'assetid')

View File

@@ -89,6 +89,13 @@ def create_app(config_name: str = None) -> Flask:
app.config.from_object(config_class) app.config.from_object(config_class)
# The product and contract versions, in config so a PLUGIN can read them
# through current_app instead of importing core (ADR-002 forbids that, and
# the contract test enforces it). Core code may still use the module
# constants directly.
app.config['VERSION'] = __version__
app.config['CONTRACT_VERSION'] = __contract_version__
# Load instance config if exists # Load instance config if exists
app.config.from_pyfile('config.py', silent=True) app.config.from_pyfile('config.py', silent=True)

View File

@@ -0,0 +1,122 @@
"""The collector reporter, generated with a site's own values.
A site used to receive a script with the reference site's server and VLANs in
it, which it had to find and edit. The server now stamps its own values into the
parameter defaults, so the file downloads ready to deploy.
What must stay true:
- ONLY the defaults are substituted. The copy in the repo stays runnable, so
there is never a second version to drift from the first.
- The collector key is NEVER in the file. It lands on every shop-floor PC, and a
token spread across hundreds of bays cannot be rotated quietly.
- Everything stamped is still overridable, because a bay may need to differ from
its site.
"""
import pytest
from shopdb.core.models import Setting
SCRIPT = '/api/computers/client-script'
def _set(db, key, value):
row = Setting.query.filter_by(key=key).first()
if row is None:
row = Setting(key=key, value=value, valuetype='string', category='computers')
db.session.add(row)
else:
row.value = value
db.session.commit()
def test_an_anonymous_caller_gets_nothing(client):
"""It states a site's URL and internal ranges - configuration, not a
handout."""
assert client.get(SCRIPT).status_code in (401, 422)
def test_it_serves_the_reporter_as_a_download(client, auth_headers):
resp = client.get(SCRIPT, headers=auth_headers)
assert resp.status_code == 200, resp.get_data(as_text=True)[:200]
assert 'attachment' in resp.headers['Content-Disposition']
assert 'Report-AssetToShopDB.ps1' in resp.headers['Content-Disposition']
body = resp.get_data(as_text=True)
assert 'param(' in body
assert 'api/collector/computers' in body
def test_it_publishes_a_hash_of_what_it_served(client, auth_headers):
"""So a deployment can verify what it fetched, like the installer does."""
import hashlib
resp = client.get(SCRIPT, headers=auth_headers)
digest = hashlib.sha256(resp.get_data()).hexdigest()
assert resp.headers['X-Script-Sha256'] == digest
def test_it_stamps_the_sites_own_url(client, db, auth_headers):
_set(db, 'site_base_url', 'https://shopdb.example.net')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$ApiUrl = 'https://shopdb.example.net/api/collector/computers'" in body
assert 'for https://shopdb.example.net' in body
def test_a_site_with_no_url_configured_still_gets_a_usable_script(client, db,
auth_headers):
"""Blank site_base_url falls back to the origin the admin is talking to,
which is by definition a reachable address for this server."""
_set(db, 'site_base_url', '')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$ApiUrl = ''" not in body
assert 'api/collector/computers' in body
def test_it_stamps_the_sites_ranges(client, db, auth_headers):
"""The replacement for the two VLANs that used to be source code."""
_set(db, 'computers_routableranges', '10.20.0.0/23,10.21.4.0/26')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$AllowedRanges = '10.20.0.0/23,10.21.4.0/26'" in body
def test_no_ranges_configured_leaves_the_default_route_fallback(client, db,
auth_headers):
"""An unconfigured site must not be given someone else's addressing: the
script falls back to the NIC carrying the default route."""
_set(db, 'computers_routableranges', '')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$AllowedRanges = ''" in body
assert 'default route' in body
def test_the_collector_key_is_never_in_the_file(client, db, auth_headers):
"""The one thing that must not be stamped in. A token in a file on every bay
is a token nobody can rotate quietly."""
_set(db, 'site_base_url', 'https://shopdb.example.net')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert 'shopdb_pat_' not in body
assert "$ApiKey = ''" in body or "[string]$ApiKey = ''" in body
# It says where the key comes from instead.
assert 'CollectorKey' in body
def test_the_body_is_not_rewritten_only_the_defaults(client, auth_headers):
"""The repo copy stays runnable. If generation started editing the body,
the file on disk and the file a site runs would diverge."""
from plugins.computers.api.routes import _client_script_path
with open(_client_script_path(), 'r', encoding='utf-8') as handle:
source = handle.read()
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
# Every line of the source survives except the two parameter defaults.
changed = [line for line in source.splitlines()
if line.strip() and line not in body.splitlines()]
assert all('$ApiUrl' in line or '$AllowedRanges' in line for line in changed), changed
def test_it_names_the_version_that_generated_it(client, auth_headers):
"""So a script found on a bay can be traced to the server that made it."""
from shopdb import __version__
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert __version__ in body