Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b3904f71 | ||
|
|
8cedf674fb | ||
|
|
0dc0ac13c8 | ||
|
|
03d0754fdc | ||
|
|
2083029ff2 | ||
|
|
96f127f8c8 | ||
|
|
f34b9ca710 |
64
CHANGELOG.md
64
CHANGELOG.md
@@ -10,6 +10,70 @@ ADR-007 and ADR-002.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.11.3] - 2026-08-19
|
||||
|
||||
Fixes the last of the buildings-and-levels bugs, and brings the shop-floor
|
||||
clients into the product: the collector reporter, EventSaver and a printer
|
||||
assignment feature that lets a bay install its own printers.
|
||||
|
||||
**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this
|
||||
asset has a position but no level" for every asset, and the map PDF printed
|
||||
markers from every floor onto one sheet.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a
|
||||
levelid, none of its seven call sites were taught to pass one - printer,
|
||||
machine and PC detail, the toner report, enforcement reports, the warranty chip
|
||||
and the dashboard cards. Two payloads behind them also emitted coordinates with
|
||||
no level.
|
||||
- **Map PDF export printed other floors' markers.** It plotted every filtered
|
||||
asset onto one sheet, which is worse than the on-screen version was: nobody can
|
||||
correct a sheet once it is printed and carried onto the floor.
|
||||
- **The legacy import loader created undrawable markers**, sending mapleft/maptop
|
||||
with no level at three call sites. That loader is the one still to run against
|
||||
production.
|
||||
- The build gate that should have caught all of this 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/`, and fails any Vue file that binds tooltip coordinates without a
|
||||
level.
|
||||
|
||||
### Added
|
||||
|
||||
- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC
|
||||
controls it, so a reimaged or swapped PC comes back with the bay's printers and
|
||||
nothing had to be saved off the old one. New `usesprinter` relationship type,
|
||||
propagating through `controls` at read time; `GET /api/printers/for-host/<host>`
|
||||
for the client; `PUT /api/printers/assignments/for-asset/<id>` to reconcile a
|
||||
whole set in one call.
|
||||
- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal
|
||||
drivers cover 41 of the reference site's 44 printers; binding a driver to a
|
||||
single model meant 21 near-duplicate rows. Resolution is model, then vendor,
|
||||
then the pre-vendorid name convention.
|
||||
- **Client scripts** in `plugins/printers/client/`: stage a site's driver set
|
||||
(`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a
|
||||
bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default
|
||||
(`Apply-ShopdbDefaultPrinter.ps1`).
|
||||
- **The collector reporter is in the repository** at
|
||||
`plugins/computers/client/`, with no site in it, and the server generates a
|
||||
copy carrying this site's URL and ranges: Settings > Computers > Asset
|
||||
reporter, or `GET /api/computers/client-script`. The collector key is
|
||||
deliberately never stamped into it.
|
||||
- **EventSaver source** at `plugins/slides/client/`. The site UNC that was
|
||||
compiled into it is gone; a missing ini now fails visibly rather than pointing
|
||||
a new site at the reference site's file server.
|
||||
- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the
|
||||
buildings-and-levels admin gains editable width AND height.
|
||||
|
||||
### Changed
|
||||
|
||||
- The map editor accepts a click. Its handler was bound only if the map was
|
||||
already a picker when it mounted, and the editor opens with nothing selected,
|
||||
so placing a marker by hand was impossible for the life of the page.
|
||||
- Global search orders every query before truncating, so the same search cannot
|
||||
return a different subset twice.
|
||||
|
||||
## [0.11.2] - 2026-08-17
|
||||
|
||||
Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it.
|
||||
|
||||
@@ -22,12 +22,12 @@ at all yet.
|
||||
| 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. |
|
||||
| `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. |
|
||||
| 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). |
|
||||
| `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.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
|
||||
run a binary it cannot rebuild should take EventSaver's source and compile it
|
||||
locally - the build needs no SDK and is one command.
|
||||
A site that would rather not run a binary it cannot rebuild should take
|
||||
EventSaver's source and compile it locally - the build needs no SDK and is one
|
||||
command.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone.
|
||||
|
||||
| Input | Where it comes from |
|
||||
|---|---|
|
||||
| Server URL | `-ApiUrl https://<your-shopdb>/api/collector/computers` |
|
||||
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` |
|
||||
| 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`. 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
|
||||
|
||||
@@ -142,13 +147,27 @@ Configuration ShopdbCollector
|
||||
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
|
||||
{
|
||||
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
|
||||
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
|
||||
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' `
|
||||
-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
|
||||
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
|
||||
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force
|
||||
|
||||
@@ -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
|
||||
ShopDB instance yet, or for content nobody wants in the database.
|
||||
|
||||
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the
|
||||
> binary falls back to a path compiled into `EventSaver.cs` - and that path
|
||||
> belongs to the site it was first built for. Ship the ini. A missing ini is not
|
||||
> a neutral default.
|
||||
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
|
||||
> no source to read and the screensaver shows nothing. That is deliberate: the
|
||||
> compiled-in fallback used to be the path of the site it was first built for, so
|
||||
> 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
|
||||
|
||||
@@ -98,8 +100,9 @@ covering something someone needed to see.
|
||||
|
||||
## Building it
|
||||
|
||||
No SDK required - it compiles with the in-box .NET Framework compiler on any
|
||||
Windows 10 or 11 machine:
|
||||
The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
|
||||
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 ^
|
||||
|
||||
@@ -11,7 +11,7 @@ never by editing this file.
|
||||
|
||||
| series | value | governed by |
|
||||
|---|---|---|
|
||||
| product `__version__` | `0.11.2` | ADR-007 |
|
||||
| product `__version__` | `0.11.3` | ADR-007 |
|
||||
| plugin contract `__contract_version__` | `0.20.0` | ADR-002 |
|
||||
|
||||
They move independently. A contract bump is not a release.
|
||||
@@ -34,7 +34,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
|
||||
| network | `network0003prefix` |
|
||||
| notifications | `notifications0005boardorder` |
|
||||
| printedparts | `printedparts0004txnrev` |
|
||||
| printers | `printers0002supplyalerts` |
|
||||
| printers | `printers0004drivervendor` |
|
||||
| slides | `slides0001anchor` |
|
||||
| usb | `usb0002dropmachineid` |
|
||||
| warranty | `warranty0002proof` |
|
||||
@@ -85,6 +85,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
|
||||
|
||||
## Size
|
||||
|
||||
- test functions defined: **1087** (parametrised cases collect higher)
|
||||
- documented API paths: **276** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||
- test functions defined: **1113** (parametrised cases collect higher)
|
||||
- documented API paths: **279** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||
|
||||
|
||||
@@ -1697,6 +1697,14 @@
|
||||
"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",
|
||||
"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"
|
||||
@@ -2880,6 +2888,22 @@
|
||||
"purpose": "Look up a PC's default printer via the defaultprinter asset relationship (parity with classic apipcdefaultprinter.asp); used by installer to preselect map hotspot.",
|
||||
"example": "curl 'http://localhost:5001/api/printers/pc-default?machine=0421&format=text'"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/printers/for-host/<hostname>",
|
||||
"auth": "jwt-optional",
|
||||
"params": "path: hostname (matched case-insensitively, as sent by the client's COMPUTERNAME); no query parameters",
|
||||
"purpose": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones inherited through its controls edge to the machine it drives. Each entry carries what a client needs to install the queue (queue name, hostname, ipaddress, port, drivername, driverlocation, isdefault, inherited). A known host with nothing assigned returns an empty list and a null default (the client's designed no-op); an unknown hostname, or a site without the computers plugin, is a 404.",
|
||||
"example": "curl http://localhost:5001/api/printers/for-host/workstation01"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/printers/assignments/for-asset/<asset_id>",
|
||||
"auth": "permission:printers.edit",
|
||||
"params": "path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)",
|
||||
"purpose": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates previously removed ones, creates new ones, and replaces the single defaultprinter row (exactly one per asset, optional, always one of the assigned printers). Removing an assignment has no side effects and never uninstalls anything on a client.",
|
||||
"example": "curl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printerassetids\":[204,205],\"defaultprinterassetid\":204}' http://localhost:5001/api/printers/assignments/for-asset/312"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/printers/<printer_id>",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -16717,6 +16755,115 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/printers/for-host/{hostname}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-printers"
|
||||
],
|
||||
"summary": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones...",
|
||||
"description": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones inherited through its controls edge to the machine it drives. Each entry carries what a client needs to install the queue (queue name, hostname, ipaddress, port, drivername, driverlocation, isdefault, inherited). A known host with nothing assigned returns an empty list and a null default (the client's designed no-op); an unknown hostname, or a site without the computers plugin, is a 404.\n\n**Auth:** jwt-optional\n\n**Params:** path: hostname (matched case-insensitively, as sent by the client's COMPUTERNAME); no query parameters\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/for-host/workstation01\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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "hostname",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/printers/assignments/for-asset/{asset_id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"plugin-printers"
|
||||
],
|
||||
"summary": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates...",
|
||||
"description": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates previously removed ones, creates new ones, and replaces the single defaultprinter row (exactly one per asset, optional, always one of the assigned printers). Removing an assignment has no side effects and never uninstalls anything on a client.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printerassetids\":[204,205],\"defaultprinterassetid\":204}' http://localhost:5001/api/printers/assignments/for-asset/312\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."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/printers/{printer_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
331
docs/proposals/printer-assignment.md
Normal file
331
docs/proposals/printer-assignment.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Proposal: assign printers to a machine in ShopDB, let the PC install them
|
||||
|
||||
Status: ACCEPTED. Server half being built 2026-08-18 (relationship types and
|
||||
rails, resolution helper, two endpoints, PC form section, `drivername`). The
|
||||
client script is 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. The printers belong to the MACHINE, not
|
||||
to the box currently driving it: tick the printers that belong on the machine,
|
||||
mark one default, and the assignment reaches whichever PC controls that machine.
|
||||
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 point of putting the assignment on the machine is that a reimaged PC needs
|
||||
no backup and no restore step. The asset register is the source of truth, and a
|
||||
replacement PC that inherits the `controls` edge inherits the printers with it.
|
||||
|
||||
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 that bay have" is a
|
||||
question ShopDB can answer, and today it cannot.
|
||||
- **A reimage stops costing a visit.** The bay reinstalls its own printers, from
|
||||
the machine's record, with nothing saved off the old PC.
|
||||
- **Swapping the PC keeps the printers.** They were never the PC's.
|
||||
- **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` |
|
||||
| Propagation mechanism | `RelationshipTypePropagation` (ADR-001): "type X propagates through connections of type Y" |
|
||||
| A working precedent | `resolve_asset_position` walks `partof` then `controls` to give a PC the machine's map position |
|
||||
| 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. The data model
|
||||
|
||||
### 4.1 One new relationship type
|
||||
|
||||
`usesprinter`, directional, source -> printer, meaning "this printer is
|
||||
installed here". It is seeded next to `defaultprinter`, which already exists and
|
||||
means "which of them is the default". Both are seed data, not a migration, which
|
||||
is how every other relationship type shipped.
|
||||
|
||||
### 4.2 Two propagation rails, consumed at READ time
|
||||
|
||||
`usesprinter` propagates through `controls`, and so does `defaultprinter`. Both
|
||||
are rows in `relationshiptypepropagations`, the same mechanism map positions
|
||||
use. Inventing a second mechanism for this was the alternative, and it was
|
||||
rejected.
|
||||
|
||||
The rails are inert at write time on purpose. The create-time fan-out
|
||||
(`propagate_relationship`) skips directional through-types, and `controls` is
|
||||
directional, so assigning a printer to a machine does not copy rows onto its PC.
|
||||
The walk happens when something asks, which is what makes the next rule possible.
|
||||
|
||||
### 4.3 Resolution order for a PC
|
||||
|
||||
1. The PC's OWN active `usesprinter` / `defaultprinter` rows, if it has any.
|
||||
2. Otherwise, one hop out along its `controls` edges to the machines it drives,
|
||||
and those machines' rows instead, tagged as inherited.
|
||||
|
||||
Own beats inherited, whole set at a time: a PC with its own assignment is
|
||||
overriding the bay, not adding to it. An office PC controls no machine and still
|
||||
works, because step 1 is the normal case for it.
|
||||
|
||||
The override is a real trap and the UI has to say so. A tech who "fixes" a bay
|
||||
by editing the PC has shadowed the machine's record, and the machine will keep
|
||||
disagreeing until someone clears the PC's own rows.
|
||||
|
||||
### 4.4 One default, optional, and never dangling
|
||||
|
||||
The unique constraint is `(source, target, type)`, which happily accepts two
|
||||
different defaults. So the rule is enforced in the API on write:
|
||||
|
||||
- Exactly one `defaultprinter` per asset. Setting a default replaces the
|
||||
existing one.
|
||||
- A default is OPTIONAL. A bay with three printers and no default is valid.
|
||||
- The default must be one of the assigned printers. Unassigning the printer that
|
||||
is currently default clears the default rather than leaving it dangling.
|
||||
|
||||
### 4.5 One column on `printerdrivers`
|
||||
|
||||
`drivername` - the driver's exact name as the INF declares it, e.g.
|
||||
`HP Universal Printing PCL 6`. `Add-PrinterDriver` matches on that string, not
|
||||
on `name`, which is ours to choose, 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.
|
||||
|
||||
It is a plugin-chain migration (`printers0003drivername`), nullable, guarded so a
|
||||
re-run is a no-op. `printerdrivers` was created by a core migration but its DDL
|
||||
moved to the printers chain at the ADR-008 cutover.
|
||||
|
||||
The `installmethod` column (`pnputil` or `dpinst`) proposed earlier is NOT being
|
||||
built. See section 8: if production confirms no Brother printers, everything is
|
||||
`pnputil` and the column has no second value to hold.
|
||||
|
||||
## 5. What has to be built
|
||||
|
||||
### 5.1 A resolution helper in core
|
||||
|
||||
The read-time walk of section 4.3, beside `resolve_asset_position` and exported
|
||||
on the `shopdb.api` contract surface (an additive minor bump). It has to live in
|
||||
core because `RelationshipTypePropagation` is not on the contract surface, and a
|
||||
plugin may not reach past it (ADR-002).
|
||||
|
||||
The through-type comes from the seeded rails, not from a hardcoded `'controls'`,
|
||||
so a site that adds a rail gets the behaviour without a code change.
|
||||
|
||||
### 5.2 Two endpoints
|
||||
|
||||
```
|
||||
GET /api/printers/for-host/<hostname>
|
||||
```
|
||||
|
||||
The desired printer set for one PC, resolved per section 4.3, each entry with
|
||||
what a client needs to install it: queue name, host or IP, port, driver name,
|
||||
driver location, and which one is default.
|
||||
|
||||
Resolved by hostname, not machine number: the collector already upserts PCs by
|
||||
hostname, and an office PC has no machine number. Matched case-insensitively -
|
||||
`COMPUTERNAME` is uppercase and MySQL forgives that where SQLite does not.
|
||||
|
||||
An unknown host, a site without the computers plugin, or nothing assigned all
|
||||
return an empty set. That is the client's designed no-op and it must stay
|
||||
indistinguishable from "assigned nothing".
|
||||
|
||||
```
|
||||
PUT /api/printers/assignments/for-asset/<asset_id>
|
||||
```
|
||||
|
||||
The whole assignment for one asset - machine or PC - in one call:
|
||||
`{printerassetids: [...], defaultprinterassetid: N|null}`. It reconciles rather
|
||||
than inserting: rows that went away are soft-deleted, rows that come back
|
||||
REACTIVATE the soft-deleted row (the unique constraint spans inactive rows, so a
|
||||
blind insert is an integrity error on assign, unassign, re-assign), new rows are
|
||||
created, and the single default is replaced.
|
||||
|
||||
The rules in 4.4 hold here or nowhere. Row-at-a-time writes through the generic
|
||||
relationships path leave two-default windows and know nothing of the subset rule.
|
||||
|
||||
**Removing an assignment NEVER uninstalls anything.** Server-side the row simply
|
||||
goes: no cascade, no side effects, nothing queued for the client to undo.
|
||||
|
||||
**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`.
|
||||
|
||||
### 5.3 UI on the PC form
|
||||
|
||||
A printer multi-select plus a default dropdown whose options are only the
|
||||
currently selected printers, clearing itself when its printer is deselected.
|
||||
Saved through the reconcile endpoint against the PC's asset.
|
||||
|
||||
The computers plugin does not depend on the printers plugin and must not start:
|
||||
the section hides itself when the printers API is not there.
|
||||
|
||||
The machine-side picker is out of scope for now, which means the machine's
|
||||
assignment is editable only through the generic relationships card. That is the
|
||||
side the design says is primary, so it is the obvious next piece of UI.
|
||||
|
||||
### 5.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.
|
||||
|
||||
## 6. Desired state and observed state are not the same thing
|
||||
|
||||
Everything above is DESIRED state: what SHOULD be installed on a PC. Nothing in
|
||||
this feature knows what IS installed on it. The client reads the desired state,
|
||||
converges toward it, and reports nothing back.
|
||||
|
||||
Reporting the observed state is the obvious next feature and is deliberately not
|
||||
this one. If the collector sent the installed queues per host - name, port,
|
||||
driver, which is default - then comparing that against the resolved assignment
|
||||
gives drift detection for free: "this bay is missing the label printer", "this
|
||||
PC has three queues nobody assigned", "the default is not the assigned one".
|
||||
|
||||
Keeping them apart is a rule, not a preference:
|
||||
|
||||
- **Observed data never writes `usesprinter` rows.** A register that learns from
|
||||
what it finds mirrors the drift instead of correcting it, and the fault
|
||||
becomes the desired state.
|
||||
- **Observed data belongs on the computer record, timestamped**, like the rest
|
||||
of the collector payload. It is an observation with an age, not a decision.
|
||||
- **An empty answer from the API means "nothing assigned", not "nothing
|
||||
installed"**, which is exactly why section 5.2 refuses to make removal
|
||||
uninstall anything.
|
||||
- The two can disagree indefinitely and that is a report to read, not an error
|
||||
to resolve automatically.
|
||||
|
||||
## 7. Decisions to take before writing the client
|
||||
|
||||
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.
|
||||
|
||||
Open on the server side, and each one changes the response contract:
|
||||
|
||||
4. **How a universal driver resolves.** `PrinterDriver` links to a printer by
|
||||
exact `modelnumberid`, and the target state is roughly four rows dominated by
|
||||
HP UPD and Xerox GPD, which match no single model. Either the driver row
|
||||
gains a vendor, or a `modelnumberid IS NULL` row matches on the printer's
|
||||
resolved vendor name. Until this is settled, `for-host` returns no driver for
|
||||
41 of 44 printers.
|
||||
5. **What `port` means when it is null.** RAW 9100 is the obvious default; whose
|
||||
job it is to apply it - server or script - has to be written down once.
|
||||
6. **Who may read `for-host`.** `pc-default` and `install-list` are anonymous;
|
||||
the collector and the GE-Enforce fetch use scoped service tokens. This one
|
||||
discloses per-PC configuration keyed by hostname.
|
||||
7. **Two inherited defaults.** A PC can legitimately control both bays of a
|
||||
dual-bay machine, or several machines. The union of assigned printers is
|
||||
easy; the default needs a deterministic rule, or none when it is ambiguous.
|
||||
8. **Legacy `defaultprinter` rows have no `usesprinter` row**, because they
|
||||
predate the type. Either an active default implies assignment on read
|
||||
(zero-touch, preferred) or a one-time backfill writes the missing rows.
|
||||
Otherwise existing defaults vanish from `for-host` while still showing in
|
||||
`pc-default`.
|
||||
9. **Deletions through the generic relationships card bypass the reconcile
|
||||
endpoint** and can strand an active default pointing at an unassigned
|
||||
printer. Either the resolver drops dangling defaults or the core delete path
|
||||
learns the rule.
|
||||
|
||||
## 8. 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 - and with them the `installmethod` column.
|
||||
- **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. Each needs `drivername` copied verbatim from its INF.
|
||||
Nothing in this proposal works until a printer can resolve to a driver.
|
||||
|
||||
## 9. 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.
|
||||
|
||||
## 10. Upgrading an existing site
|
||||
|
||||
Three steps, and the third is the one that gets forgotten:
|
||||
|
||||
1. `flask db upgrade` - no core migration in this feature, but a deploy runs it.
|
||||
2. `flask plugin upgrade-all` - applies `drivername`. Skipping it is the classic
|
||||
1054 unknown-column error.
|
||||
3. `flask seed reference-data` - REQUIRED. Without it the `usesprinter` type and
|
||||
both propagation rails do not exist, and `for-host` resolves nothing, quietly,
|
||||
because empty is also the healthy answer.
|
||||
|
||||
Pair the upgrade with a smoke check against a known bay. A site with reversed
|
||||
legacy `controls` rows (machine -> PC) should run
|
||||
`flask relationships fix-controls-direction` first, or inheritance resolves for
|
||||
none of those PCs.
|
||||
|
||||
## 11. 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. Section 6 would change it; this feature does not.
|
||||
- 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.11.2",
|
||||
"version": "0.11.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<!-- With coordinates, the name carries the same floor-plan preview
|
||||
the asset's own page uses. Without them, a plain link. -->
|
||||
<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">
|
||||
<router-link :to="row.link" class="dc-row-title"
|
||||
:title="row.titletip || undefined">
|
||||
|
||||
@@ -127,7 +127,15 @@ export function mapHover(card, item) {
|
||||
const x = item[spec.x]
|
||||
const y = item[spec.y]
|
||||
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) {
|
||||
|
||||
@@ -289,7 +289,12 @@ async function exportPdf() {
|
||||
try {
|
||||
await loadMapConfig()
|
||||
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
|
||||
// root-relative /api path, which 404s under a subpath mount like /ops.
|
||||
// The PDF always uses the light blueprint: it prints on white paper.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""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 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 shopdb.api import require_permission, apply_import_timestamps
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||
|
||||
computers_bp = Blueprint('computers', __name__)
|
||||
|
||||
@@ -1064,3 +1064,112 @@ def dashboard_sharedmachines():
|
||||
|
||||
out.sort(key=lambda r: -r['pccount'])
|
||||
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,
|
||||
})
|
||||
|
||||
328
plugins/computers/client/Report-AssetToShopDB.ps1
Normal file
328
plugins/computers/client/Report-AssetToShopDB.ps1
Normal 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
|
||||
@@ -39,6 +39,12 @@ export default [
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/collector',
|
||||
name: 'collector-settings',
|
||||
component: () => import('./views/CollectorSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
|
||||
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal 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>
|
||||
@@ -185,6 +185,7 @@
|
||||
v-if="computer.mapx != null && computer.mapy != null"
|
||||
:left="computer.mapx"
|
||||
:top="computer.mapy"
|
||||
:levelid="computer.levelid"
|
||||
:machineName="computer.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
||||
|
||||
@@ -245,6 +245,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer assignment. Written as plain asset relationships, so this
|
||||
section is absent at a site without the printers plugin. -->
|
||||
<template v-if="printersEnabled">
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printers</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="printersearch">Assigned Printers</label>
|
||||
<input
|
||||
id="printersearch"
|
||||
v-model="printerSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Filter printers..."
|
||||
/>
|
||||
<div class="printer-list">
|
||||
<label
|
||||
v-for="printerAsset in filteredPrinters"
|
||||
:key="printerAsset.assetid"
|
||||
class="printer-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPrinterAssigned(printerAsset.assetid)"
|
||||
@change="togglePrinter(printerAsset.assetid, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ printerLabel(printerAsset) }}</span>
|
||||
<span v-if="printerAsset.printer?.modelname" class="printer-meta">
|
||||
{{ printerAsset.printer.modelname }}
|
||||
</span>
|
||||
</label>
|
||||
<span v-if="!filteredPrinters.length" class="muted">No printers match.</span>
|
||||
</div>
|
||||
<small class="form-hint">
|
||||
{{ assignedPrinters.length }} assigned. Printers ticked here belong to this PC
|
||||
and take the place of any assigned to the machine it controls.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="defaultprinterassetid">Default Printer</label>
|
||||
<select
|
||||
id="defaultprinterassetid"
|
||||
v-model="defaultPrinterAssetId"
|
||||
class="form-control"
|
||||
>
|
||||
<option :value="null">No default</option>
|
||||
<option
|
||||
v-for="printerAsset in assignedPrinters"
|
||||
:key="printerAsset.assetid"
|
||||
:value="printerAsset.assetid"
|
||||
>
|
||||
{{ printerLabel(printerAsset) }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">
|
||||
Optional, and only ever one of the printers assigned above.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -317,7 +377,8 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
||||
printersApi, relationshipTypesApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
@@ -325,8 +386,11 @@ import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -412,6 +476,191 @@ const models = ref([])
|
||||
const locations = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
// Printer assignment. The rows are ordinary asset relationships - usesprinter
|
||||
// for "installed here", defaultprinter for which of them wins - so the picker
|
||||
// reads and writes them through the generic relationship endpoints.
|
||||
const printersEnabled = ref(false)
|
||||
const printers = ref([])
|
||||
const printerSearch = ref('')
|
||||
const assignedPrinterAssetIds = ref([])
|
||||
const defaultPrinterAssetId = ref(null)
|
||||
const printerRelationshipTypes = ref({ usesprinter: null, defaultprinter: null })
|
||||
// The rows as loaded, so saving writes only what actually changed.
|
||||
const existingPrinterRelationships = ref([])
|
||||
|
||||
function printerLabel(printerAsset) {
|
||||
const name = printerAsset.name && printerAsset.name.toUpperCase() !== 'NONE'
|
||||
? printerAsset.name
|
||||
: null
|
||||
return name || printerAsset.printer?.hostname || printerAsset.assetnumber
|
||||
|| `Asset ${printerAsset.assetid}`
|
||||
}
|
||||
|
||||
const sortedPrinters = computed(() =>
|
||||
[...printers.value].sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
const term = printerSearch.value.trim().toLowerCase()
|
||||
if (!term) return sortedPrinters.value
|
||||
return sortedPrinters.value.filter(printerAsset => {
|
||||
const haystack = [
|
||||
printerLabel(printerAsset),
|
||||
printerAsset.assetnumber || '',
|
||||
printerAsset.printer?.modelname || ''
|
||||
].join(' ').toLowerCase()
|
||||
return haystack.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
// Drives the default dropdown, so the default can only ever be one of the
|
||||
// assigned printers. A printer filtered out of the list above is still here.
|
||||
const assignedPrinters = computed(() =>
|
||||
assignedPrinterAssetIds.value
|
||||
.map(assetid => printers.value.find(printerAsset => printerAsset.assetid === assetid))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||
|
||||
function isPrinterAssigned(assetid) {
|
||||
return assignedPrinterAssetIds.value.includes(assetid)
|
||||
}
|
||||
|
||||
function togglePrinter(assetid, on) {
|
||||
if (on) {
|
||||
if (!isPrinterAssigned(assetid)) {
|
||||
assignedPrinterAssetIds.value = [...assignedPrinterAssetIds.value, assetid]
|
||||
}
|
||||
} else {
|
||||
assignedPrinterAssetIds.value = assignedPrinterAssetIds.value.filter(id => id !== assetid)
|
||||
}
|
||||
}
|
||||
|
||||
// Unassigning the printer that is currently default clears the default rather
|
||||
// than leaving one pointing at a printer this PC no longer has.
|
||||
watch(assignedPrinterAssetIds, (assetids) => {
|
||||
if (defaultPrinterAssetId.value && !assetids.includes(defaultPrinterAssetId.value)) {
|
||||
defaultPrinterAssetId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// Printer list + the two relationship type ids. Both types are seed data
|
||||
// (flask seed reference-data); without them there is nothing to write, so the
|
||||
// section stays hidden rather than offering a control that cannot save.
|
||||
async function loadPrinterOptions() {
|
||||
try {
|
||||
await loadEnabledPlugins()
|
||||
if (!isPluginEnabled('printers')) return
|
||||
const [printerRows, typeResponse] = await Promise.all([
|
||||
printersApi.listAll(),
|
||||
relationshipTypesApi.list()
|
||||
])
|
||||
const types = typeResponse.data.data || []
|
||||
const typeIdFor = (name) =>
|
||||
types.find(t => t.relationshiptype === name)?.relationshiptypeid || null
|
||||
printerRelationshipTypes.value = {
|
||||
usesprinter: typeIdFor('usesprinter'),
|
||||
defaultprinter: typeIdFor('defaultprinter')
|
||||
}
|
||||
printers.value = printerRows || []
|
||||
printersEnabled.value = !!(printerRelationshipTypes.value.usesprinter
|
||||
&& printerRelationshipTypes.value.defaultprinter)
|
||||
} catch (printerError) {
|
||||
console.error('Error loading printers:', printerError)
|
||||
printersEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrinterAssignments(assetid) {
|
||||
if (!printersEnabled.value || !assetid) return
|
||||
try {
|
||||
const response = await assetsApi.getRelationships(assetid)
|
||||
const types = printerRelationshipTypes.value
|
||||
existingPrinterRelationships.value = (response.data.data?.outgoing || []).filter(
|
||||
rel => rel.relationshiptypeid === types.usesprinter
|
||||
|| rel.relationshiptypeid === types.defaultprinter
|
||||
)
|
||||
assignedPrinterAssetIds.value = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||
.map(rel => rel.targetassetid)
|
||||
const currentDefault = existingPrinterRelationships.value
|
||||
.find(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||
defaultPrinterAssetId.value = currentDefault ? currentDefault.targetassetid : null
|
||||
// Defaults set before this form existed have no usesprinter row. Show that
|
||||
// printer as assigned: a default missing from the list reads as data loss,
|
||||
// and saving then writes the row that was never there.
|
||||
if (defaultPrinterAssetId.value
|
||||
&& !assignedPrinterAssetIds.value.includes(defaultPrinterAssetId.value)) {
|
||||
assignedPrinterAssetIds.value = [
|
||||
...assignedPrinterAssetIds.value, defaultPrinterAssetId.value
|
||||
]
|
||||
}
|
||||
// /printers lists active printers only, so a retired one that is still
|
||||
// assigned would be missing from every control on this form - unable to be
|
||||
// unticked, and blank in the default box. The relationship carries the
|
||||
// asset, so add it to the list it fell out of.
|
||||
for (const rel of existingPrinterRelationships.value) {
|
||||
const target = rel.targetasset
|
||||
if (target && !printers.value.some(known => known.assetid === target.assetid)) {
|
||||
printers.value = [...printers.value, target]
|
||||
}
|
||||
}
|
||||
} catch (printerError) {
|
||||
console.error('Error loading printer assignment:', printerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile the PC's own printer rows against the picker. Row at a time
|
||||
// through the generic relationship endpoints - there is no single assignment
|
||||
// endpoint yet - so the order matters: the outgoing default goes before the
|
||||
// incoming one lands, because the unique key is (source, target, type) and
|
||||
// would let two different defaults sit side by side. Re-creating a row that
|
||||
// was removed earlier is safe; the create path reactivates the soft-deleted
|
||||
// one instead of inserting a duplicate.
|
||||
async function savePrinterAssignments(assetid) {
|
||||
const types = printerRelationshipTypes.value
|
||||
const assigned = assignedPrinterAssetIds.value
|
||||
const wanteddefault = defaultPrinterAssetId.value
|
||||
|
||||
const assignedRows = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||
const defaultRows = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||
|
||||
try {
|
||||
// Removing an assignment removes the row and nothing else. It never
|
||||
// uninstalls a queue anywhere.
|
||||
for (const rel of assignedRows) {
|
||||
if (!assigned.includes(rel.targetassetid)) {
|
||||
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||
}
|
||||
}
|
||||
for (const printerassetid of assigned) {
|
||||
if (!assignedRows.some(rel => rel.targetassetid === printerassetid)) {
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: assetid,
|
||||
targetassetid: printerassetid,
|
||||
relationshiptypeid: types.usesprinter
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const rel of defaultRows) {
|
||||
if (rel.targetassetid !== wanteddefault) {
|
||||
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||
}
|
||||
}
|
||||
if (wanteddefault && !defaultRows.some(rel => rel.targetassetid === wanteddefault)) {
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: assetid,
|
||||
targetassetid: wanteddefault,
|
||||
relationshiptypeid: types.defaultprinter
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Part of the reconcile may have landed, so what the form believes is
|
||||
// stored has to come from the server before anyone saves again.
|
||||
await loadPrinterAssignments(assetid)
|
||||
}
|
||||
}
|
||||
|
||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||
watch(() => form.value.serialnumber, (serial) => {
|
||||
if (!isEdit.value && !manualPcNumber.value && serial) {
|
||||
@@ -438,7 +687,9 @@ onMounted(async () => {
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list()
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
loadPrinterOptions()
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
@@ -482,6 +733,8 @@ onMounted(async () => {
|
||||
levelid: pc.levelid ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
|
||||
await loadPrinterAssignments(currentAssetId.value)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
@@ -570,6 +823,19 @@ async function savePC() {
|
||||
}
|
||||
}
|
||||
|
||||
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
|
||||
// whose Save would create a second PC is the worse failure - but a printer
|
||||
// assignment that quietly did not happen is the bug this feature exists to
|
||||
// stop, so it has to be said out loud.
|
||||
if (assetId && printersEnabled.value) {
|
||||
try {
|
||||
await savePrinterAssignments(assetId)
|
||||
} catch (printerError) {
|
||||
console.error('Error saving printer assignment:', printerError)
|
||||
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/pcs')
|
||||
} catch (err) {
|
||||
console.error('Error saving PC:', err)
|
||||
@@ -603,6 +869,30 @@ async function savePC() {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Scrolls rather than pushing the rest of the form off screen: a site can hold
|
||||
dozens of printers. */
|
||||
.printer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.printer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.printer-meta {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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]:
|
||||
"""Settings this plugin owns.
|
||||
|
||||
@@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin):
|
||||
'description': 'Hours without a collector report before a PC '
|
||||
'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',
|
||||
'value': 'false',
|
||||
|
||||
@@ -1223,6 +1223,9 @@ def list_reports():
|
||||
'location': known.get('location'),
|
||||
'mapx': known.get('mapx'),
|
||||
'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'),
|
||||
'machineassetid': known.get('machineassetid'),
|
||||
'machinepluginid': known.get('machinepluginid'),
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
empty map would be worse than none. -->
|
||||
<LocationMapTooltip
|
||||
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"
|
||||
>
|
||||
<span class="map-pin" :title="report.location || 'On the floor plan'">◎</span>
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
v-if="machine.mapx != null && machine.mapy != null"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:levelid="machine.levelid"
|
||||
:machineName="machine.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||
|
||||
@@ -638,6 +638,481 @@ def pc_default_printer():
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Printer assignment resolution (which printers belong on a PC)
|
||||
# =============================================================================
|
||||
|
||||
# The assignment edges. usesprinter says a printer is installed here;
|
||||
# defaultprinter says which of them Windows should default to.
|
||||
_USES_PRINTER = 'usesprinter'
|
||||
_DEFAULT_PRINTER = 'defaultprinter'
|
||||
_CONTROLS = 'controls'
|
||||
|
||||
|
||||
def _relationship_typeids(*names):
|
||||
"""{name: [relationshiptypeid, ...]} for the named relationship types.
|
||||
|
||||
A list per name, not an id: MySQL's default collation is case-insensitive,
|
||||
so a legacy 'Controls' row lives happily beside 'controls' and a walk that
|
||||
picked one of them would silently miss half the data. Names absent from the
|
||||
table map to an empty list, which resolves to no printers rather than an
|
||||
error - an un-seeded database is a deployment step missed, not a bad request.
|
||||
"""
|
||||
wanted = {name.lower(): [] for name in names}
|
||||
rows = RelationshipType.query.filter(
|
||||
RelationshipType.relationshiptype.in_(names)).all()
|
||||
for row in rows:
|
||||
key = (row.relationshiptype or '').lower()
|
||||
if key in wanted:
|
||||
wanted[key].append(row.relationshiptypeid)
|
||||
return wanted
|
||||
|
||||
|
||||
def _outgoing_rows(assetid, typeids):
|
||||
"""Active outgoing relationships of the given types, oldest first."""
|
||||
if not typeids:
|
||||
return []
|
||||
return (AssetRelationship.query
|
||||
.filter(AssetRelationship.sourceassetid == assetid,
|
||||
AssetRelationship.relationshiptypeid.in_(typeids),
|
||||
AssetRelationship.isactive == True)
|
||||
.order_by(AssetRelationship.relationshipid)
|
||||
.all())
|
||||
|
||||
|
||||
def _own_assignment(assetid, typeids):
|
||||
"""One asset's OWN assignment: (ordered printer assetids, default assetid).
|
||||
|
||||
On an asset with NO usesprinter rows, a defaultprinter row is the whole
|
||||
assignment. Those rows predate this feature - the installer preselect and
|
||||
the collector both write them - and ignoring them would take printers away
|
||||
from every PC recorded before assignment existed. Once an asset has
|
||||
usesprinter rows it is managed, and a default outside that set is stale
|
||||
rather than legacy, so it is dropped by _assignment_result.
|
||||
|
||||
Two active defaults cannot be prevented by the schema - the unique
|
||||
constraint is (source, target, type) - so the oldest row wins and the rest
|
||||
are ignored, which at least makes the answer the same on every read.
|
||||
"""
|
||||
printerassetids = []
|
||||
for rel in _outgoing_rows(assetid, typeids[_USES_PRINTER]):
|
||||
if rel.targetassetid not in printerassetids:
|
||||
printerassetids.append(rel.targetassetid)
|
||||
ismanaged = bool(printerassetids)
|
||||
|
||||
defaultassetid = None
|
||||
for rel in _outgoing_rows(assetid, typeids[_DEFAULT_PRINTER]):
|
||||
if not ismanaged and rel.targetassetid not in printerassetids:
|
||||
printerassetids.append(rel.targetassetid)
|
||||
if defaultassetid is None:
|
||||
defaultassetid = rel.targetassetid
|
||||
|
||||
return printerassetids, defaultassetid
|
||||
|
||||
|
||||
def resolve_asset_printers(asset):
|
||||
"""Which printers an asset gets, and which one is default.
|
||||
|
||||
Own rows first; only when the asset has none does the walk follow its
|
||||
outgoing controls edges one hop and take the assignment of whatever it
|
||||
controls.
|
||||
|
||||
THE INHERITANCE IS THE FEATURE. Printers are a property of the bay, not of
|
||||
the box sat next to it: the machine holds the assignment, and whichever PC
|
||||
controls that machine picks it up. So a PC that is reimaged, or swapped for
|
||||
a different chassis entirely, resolves the same printers on its next cycle
|
||||
with nothing backed up and nothing restored. A PC that controls no machine -
|
||||
an office PC - has only its own rows, which is the same code path with an
|
||||
empty walk.
|
||||
|
||||
A PC's own rows SHADOW what it would inherit rather than adding to it, so a
|
||||
one-off printer on a bay PC is expressed by assigning that PC everything it
|
||||
should have, not by hoping two sets merge.
|
||||
|
||||
Returns {'assignments': [{'assetid', 'isdefault', 'inheritedfromassetid'}],
|
||||
'source': 'self' | 'inherited' | 'none'}.
|
||||
"""
|
||||
assetid = getattr(asset, 'assetid', None)
|
||||
if assetid is None:
|
||||
return {'assignments': [], 'source': 'none'}
|
||||
|
||||
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
|
||||
|
||||
printerassetids, defaultassetid = _own_assignment(assetid, typeids)
|
||||
if printerassetids:
|
||||
return _assignment_result(printerassetids, defaultassetid, None)
|
||||
|
||||
# Nothing of its own: take the bay's. Outgoing controls only (PC -> machine,
|
||||
# the direction `flask relationships fix-controls-direction` enforces).
|
||||
inherited = []
|
||||
defaults = []
|
||||
suppliers = {}
|
||||
for rel in _outgoing_rows(assetid, typeids[_CONTROLS]):
|
||||
machine = rel.targetasset
|
||||
if machine is None or not getattr(machine, 'isactive', True):
|
||||
continue
|
||||
machineprinters, machinedefault = _own_assignment(machine.assetid, typeids)
|
||||
for printerassetid in machineprinters:
|
||||
if printerassetid not in inherited:
|
||||
inherited.append(printerassetid)
|
||||
suppliers[printerassetid] = machine.assetid
|
||||
if machinedefault is not None and machinedefault not in defaults:
|
||||
defaults.append(machinedefault)
|
||||
|
||||
if not inherited:
|
||||
return {'assignments': [], 'source': 'none'}
|
||||
|
||||
# A PC controlling several machines (or both bays of a dualpath pair) can
|
||||
# inherit two different defaults. Union the printers, but refuse to guess a
|
||||
# default: no default is a state the client already handles, a coin toss is
|
||||
# not.
|
||||
if len(defaults) > 1:
|
||||
logger.warning(
|
||||
'Asset %s inherits %d conflicting default printers; leaving default unset',
|
||||
assetid, len(defaults))
|
||||
inheriteddefault = None
|
||||
else:
|
||||
inheriteddefault = defaults[0] if defaults else None
|
||||
|
||||
return _assignment_result(inherited, inheriteddefault, suppliers)
|
||||
|
||||
|
||||
def _assignment_result(printerassetids, defaultassetid, suppliers):
|
||||
"""Shape the resolver's answer. suppliers is None for an asset's own rows."""
|
||||
# Settled rule: the default must be one of the assigned printers. A dangling
|
||||
# default happens when a printer is unassigned through the generic
|
||||
# relationships card, which knows nothing about this pairing.
|
||||
if defaultassetid not in printerassetids:
|
||||
defaultassetid = None
|
||||
return {
|
||||
'assignments': [{
|
||||
'assetid': printerassetid,
|
||||
'isdefault': printerassetid == defaultassetid,
|
||||
'inheritedfromassetid': (suppliers or {}).get(printerassetid),
|
||||
} for printerassetid in printerassetids],
|
||||
'source': 'inherited' if suppliers is not None else 'self',
|
||||
}
|
||||
|
||||
|
||||
def _printer_driver(printer, universaldrivers):
|
||||
"""Driver record to install this printer with, or None.
|
||||
|
||||
Three steps, most specific first:
|
||||
|
||||
1. A driver bound to the printer's MODEL. A plotter, a card printer and a
|
||||
label printer each need their own, and a per-model row must beat the
|
||||
universal one.
|
||||
2. A driver bound to the printer's VENDOR with no model. HP's and Xerox's
|
||||
universal drivers cover 41 of the reference site's 44 printers between
|
||||
them; binding those to one model each would mean a near-duplicate row per
|
||||
model, which is a table nobody keeps true.
|
||||
3. Failing both, a model-less driver whose NAME carries the vendor word.
|
||||
This is the pre-vendorid convention, kept so a site that populated its
|
||||
table before the column existed does not lose its drivers on upgrade.
|
||||
"""
|
||||
if printer.modelnumberid:
|
||||
driver = (PrinterDriver.query
|
||||
.filter_by(modelnumberid=printer.modelnumberid, isactive=True)
|
||||
.order_by(PrinterDriver.name).first())
|
||||
if driver:
|
||||
return driver
|
||||
|
||||
if printer.vendorid:
|
||||
for driver in universaldrivers:
|
||||
if driver.vendorid == printer.vendorid:
|
||||
return driver
|
||||
|
||||
vendor = _printer_vendor(printer).lower()
|
||||
if not vendor:
|
||||
return None
|
||||
for driver in universaldrivers:
|
||||
# Only the legacy convention here: a row WITH a vendorid that did not
|
||||
# match above must not be matched by its name instead, or a mis-set
|
||||
# vendor silently resolves to the wrong package.
|
||||
if driver.vendorid:
|
||||
continue
|
||||
if vendor in (driver.name or '').lower():
|
||||
return driver
|
||||
return None
|
||||
|
||||
|
||||
def _computer_by_hostname(hostname):
|
||||
"""Active computer asset matching a reported hostname, or None.
|
||||
|
||||
Case-folded on both sides: COMPUTERNAME arrives uppercase, MySQL forgives
|
||||
that and SQLite does not, so an uncompared case would work in production and
|
||||
fail in the tests (or the other way round on a binary collation).
|
||||
|
||||
A short name also matches a stored FQDN, and an FQDN matches a stored short
|
||||
name, because which of the two a site records is a matter of how its PCs
|
||||
were enrolled and the client only ever knows its own COMPUTERNAME.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
name = (hostname or '').strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
query = db.session.query(Computer, Asset).join(
|
||||
Asset, Asset.assetid == Computer.assetid).filter(Asset.isactive == True)
|
||||
|
||||
row = query.filter(db.func.lower(Computer.hostname) == name).first()
|
||||
if row:
|
||||
return row
|
||||
|
||||
shortname = name.split('.')[0]
|
||||
if shortname != name:
|
||||
row = query.filter(db.func.lower(Computer.hostname) == shortname).first()
|
||||
if row:
|
||||
return row
|
||||
# Prefix match only for a plain hostname: LIKE wildcards in a path segment
|
||||
# would otherwise let '%' pull back somebody else's printers.
|
||||
if not re.match(r'^[a-z0-9-]+$', shortname):
|
||||
return None
|
||||
return query.filter(
|
||||
db.func.lower(Computer.hostname).like(shortname + '.%')).first()
|
||||
|
||||
|
||||
@printers_asset_bp.route('/for-host/<hostname>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def printers_for_host(hostname: str):
|
||||
"""Printers assigned to a PC, by hostname, with what it takes to install one.
|
||||
|
||||
The endpoint the convergence client asks on every cycle: give me the state
|
||||
this host should be in. Resolution is own rows, else the assignment of the
|
||||
machine this PC controls (see resolve_asset_printers) - which is why a
|
||||
reimaged bay reinstalls its own printers.
|
||||
|
||||
Resolved by hostname rather than machine number because the collector
|
||||
upserts PCs by hostname and an office PC has no machine number at all.
|
||||
|
||||
404 when the host is unknown. A known host with nothing assigned is an
|
||||
empty list and a null default, not an error: that is the client's no-op.
|
||||
|
||||
Each printer carries queuename (what to call the queue), hostname/ipaddress
|
||||
(where to point the port), port (null means the client's own default raw
|
||||
port), drivername (verbatim from the INF, what Add-PrinterDriver matches on)
|
||||
and driverlocation (where the package lives).
|
||||
"""
|
||||
try:
|
||||
row = _computer_by_hostname(hostname)
|
||||
except ImportError:
|
||||
# No computers plugin, no way to resolve a hostname to an asset.
|
||||
row = None
|
||||
|
||||
if not row:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No computer found with hostname {hostname}',
|
||||
http_code=404)
|
||||
|
||||
computer, asset = row
|
||||
resolved = resolve_asset_printers(asset)
|
||||
assignments = resolved['assignments']
|
||||
|
||||
printers = []
|
||||
if assignments:
|
||||
assetids = [item['assetid'] for item in assignments]
|
||||
rows = (db.session.query(Printer)
|
||||
.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.filter(Printer.assetid.in_(assetids))
|
||||
.filter(Asset.isactive == True)
|
||||
.all())
|
||||
byassetid = {printer.assetid: printer for printer in rows}
|
||||
|
||||
# Fetched once: the universal-driver fallback would otherwise re-read
|
||||
# the same handful of rows per printer.
|
||||
universaldrivers = (PrinterDriver.query
|
||||
.filter(PrinterDriver.modelnumberid.is_(None),
|
||||
PrinterDriver.isactive == True)
|
||||
.order_by(PrinterDriver.name).all())
|
||||
|
||||
for item in assignments:
|
||||
printer = byassetid.get(item['assetid'])
|
||||
if not printer:
|
||||
# Assigned asset is retired, or is not a printer at all.
|
||||
continue
|
||||
printerasset = printer.asset
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=printer.assetid, isprimary=True).first() \
|
||||
or Communication.query.filter_by(assetid=printer.assetid).first()
|
||||
driver = _printer_driver(printer, universaldrivers)
|
||||
printers.append({
|
||||
'printerid': printer.printerid,
|
||||
'assetid': printer.assetid,
|
||||
'queuename': _install_name(printer, printerasset),
|
||||
'windowsname': printer.windowsname,
|
||||
'sharename': printer.sharename,
|
||||
'hostname': printer.hostname,
|
||||
'ipaddress': primary.ipaddress if primary else None,
|
||||
'port': primary.port if primary else None,
|
||||
'driverid': driver.driverid if driver else None,
|
||||
'drivername': driver.drivername if driver else None,
|
||||
'driverlocation': driver.location if driver else None,
|
||||
'installpath': printer.installpath,
|
||||
'isdefault': item['isdefault'],
|
||||
'inheritedfromassetid': item['inheritedfromassetid'],
|
||||
})
|
||||
|
||||
default = next((p for p in printers if p['isdefault']), None)
|
||||
|
||||
return success_response({
|
||||
'hostname': computer.hostname,
|
||||
'assetid': asset.assetid,
|
||||
'assetnumber': asset.assetnumber,
|
||||
# Where the assignment came from, so a technician reading a client log
|
||||
# can tell a bay's printers from the PC's own overrides.
|
||||
'source': resolved['source'],
|
||||
'defaultprinterid': default['printerid'] if default else None,
|
||||
'printers': printers,
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def set_asset_printer_assignment(asset_id: int):
|
||||
"""Reconcile one asset's whole printer assignment in a single call.
|
||||
|
||||
Body: {"printerassetids": [...], "defaultprinterassetid": N or null}.
|
||||
|
||||
The WHOLE set, not a delta, because the caller knows the intended end state
|
||||
and a row-at-a-time edit is a non-atomic reconcile: an HTTP failure part way
|
||||
leaves an asset half-assigned, with nothing recording what was meant.
|
||||
|
||||
Written against the MACHINE for a bay - that is the point of the feature, so
|
||||
a reimaged PC inherits it - but an asset is an asset here, and writing to a
|
||||
PC deliberately shadows its machine (see resolve_asset_printers).
|
||||
|
||||
Rows that go away are SOFT-deleted and rows that come back are REACTIVATED
|
||||
rather than inserted: the unique constraint (source, target, type) spans
|
||||
inactive rows, so a blind insert after an unassign raises IntegrityError on
|
||||
MySQL while passing on SQLite.
|
||||
|
||||
Removal here uninstalls nothing. It changes what the bay is told to have;
|
||||
the client never deletes a queue.
|
||||
"""
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
if not asset or not asset.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
raw = data.get('printerassetids')
|
||||
if raw is None or not isinstance(raw, list):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'printerassetids must be a list of asset ids')
|
||||
|
||||
# Ordered, de-duplicated: the same printer twice is one assignment, and the
|
||||
# order is the order the client is told to install them in.
|
||||
wanted = []
|
||||
for value in raw:
|
||||
try:
|
||||
assetid = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'printerassetids must be integers')
|
||||
if assetid not in wanted:
|
||||
wanted.append(assetid)
|
||||
|
||||
defaultid = data.get('defaultprinterassetid')
|
||||
if defaultid is not None:
|
||||
try:
|
||||
defaultid = int(defaultid)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'defaultprinterassetid must be an asset id or null')
|
||||
# Checked BEFORE any write, so a rejected request changes nothing. A
|
||||
# default outside the set tells the client to default to a queue it was
|
||||
# never told to install: it fails, and nothing in ShopDB says why.
|
||||
if defaultid not in wanted:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'defaultprinterassetid must be one of printerassetids')
|
||||
|
||||
# Every target must exist and be a printer. Assigning a machine to a machine
|
||||
# is a typo that would otherwise sit in the data until a bay tried it.
|
||||
if wanted:
|
||||
found = {row.assetid: row for row in
|
||||
Asset.query.filter(Asset.assetid.in_(wanted)).all()}
|
||||
missing = [assetid for assetid in wanted if assetid not in found]
|
||||
if missing:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'Unknown printer asset(s): {0}'.format(
|
||||
', '.join(str(assetid) for assetid in missing)),
|
||||
http_code=404)
|
||||
notprinters = [assetid for assetid, row in found.items()
|
||||
if not (row.assettype and row.assettype.assettype == 'printer')]
|
||||
if notprinters:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Not printer assets: {0}'.format(
|
||||
', '.join(str(assetid) for assetid in sorted(notprinters))))
|
||||
|
||||
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
|
||||
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
|
||||
# Seed data, not a migration. An un-seeded database cannot hold an
|
||||
# assignment, and saying so beats writing rows nothing can read.
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Relationship types are not seeded - run: flask seed reference-data',
|
||||
http_code=500)
|
||||
|
||||
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
|
||||
typeids[_USES_PRINTER], wanted)
|
||||
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
|
||||
typeids[_DEFAULT_PRINTER],
|
||||
[defaultid] if defaultid is not None else [])
|
||||
db.session.commit()
|
||||
|
||||
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
||||
return success_response({
|
||||
'assetid': asset_id,
|
||||
'printerassetids': printerassetids,
|
||||
'defaultprinterassetid': defaultassetid,
|
||||
}, message='Printer assignment updated')
|
||||
|
||||
|
||||
def _reconcile_edges(sourceassetid, writetypeid, readtypeids, wantedtargets):
|
||||
"""Make the active edges of one type be exactly `wantedtargets`.
|
||||
|
||||
Reads across every case-variant type id (a legacy 'DefaultPrinter' row is
|
||||
the same edge) but writes new rows with one, so the table converges on a
|
||||
single spelling instead of accumulating both.
|
||||
"""
|
||||
existing = {}
|
||||
rows = (AssetRelationship.query
|
||||
.filter(AssetRelationship.sourceassetid == sourceassetid,
|
||||
AssetRelationship.relationshiptypeid.in_(readtypeids))
|
||||
.order_by(AssetRelationship.relationshipid)
|
||||
.all())
|
||||
for row in rows:
|
||||
existing.setdefault(row.targetassetid, []).append(row)
|
||||
|
||||
for targetassetid, rowlist in existing.items():
|
||||
if targetassetid in wantedtargets:
|
||||
# Keep the oldest, retire any duplicate: two active rows for one
|
||||
# edge is how an asset ends up with two defaults.
|
||||
keep = rowlist[0]
|
||||
keep.isactive = True
|
||||
for extra in rowlist[1:]:
|
||||
extra.isactive = False
|
||||
else:
|
||||
for row in rowlist:
|
||||
row.isactive = False
|
||||
|
||||
for targetassetid in wantedtargets:
|
||||
if targetassetid not in existing:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=sourceassetid,
|
||||
targetassetid=targetassetid,
|
||||
relationshiptypeid=writetypeid,
|
||||
isactive=True))
|
||||
|
||||
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_printer(printer_id: int):
|
||||
@@ -1042,7 +1517,7 @@ def _get_low_supplies_data():
|
||||
'model': model_number,
|
||||
'location': location_name,
|
||||
'mapx': asset.mapx,
|
||||
'levelid': asset.levelid,
|
||||
'levelid': asset.levelid,
|
||||
'mapy': asset.mapy,
|
||||
'supplies': annotated
|
||||
})
|
||||
@@ -1479,6 +1954,10 @@ def dashboard_supplies():
|
||||
# card, it just has nothing to preview.
|
||||
'mapx': printer.get('mapx'),
|
||||
'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),
|
||||
'supplies': [{
|
||||
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
||||
|
||||
94
plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1
Normal file
94
plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1
Normal file
@@ -0,0 +1,94 @@
|
||||
# Apply-ShopdbDefaultPrinter.ps1
|
||||
#
|
||||
# Sets the logged-on user's default printer to the one ShopDB assigned. Runs IN
|
||||
# THE USER'S CONTEXT, at logon and on a repeat, because a default printer is
|
||||
# per-user state that SYSTEM cannot set for somebody else.
|
||||
#
|
||||
# Set-ShopdbPrinters.ps1 records the desired queue in
|
||||
# HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter during the enforcement cycle. This
|
||||
# reads it. Splitting the two is not tidiness: the machine half needs SYSTEM and
|
||||
# the share, the user half needs a user - no single process has both.
|
||||
#
|
||||
# IT ALSO TURNS OFF "Let Windows manage my default printer". Leaving it on means
|
||||
# Windows silently overwrites the choice the next time somebody prints to another
|
||||
# queue, and the bay drifts back with nothing in any log to say why.
|
||||
#
|
||||
# Converges: when the current default already matches, it does nothing, so a
|
||||
# repeating trigger costs a registry read. A user who deliberately picks another
|
||||
# default WILL be corrected on the next run - that is the intent for a shared
|
||||
# bay. For a PC where that is wrong, schedule it at logon only.
|
||||
#
|
||||
# Exits 0 always.
|
||||
|
||||
param(
|
||||
# Override for testing. Normally read from the machine hive.
|
||||
[string]$PrinterName = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$logDir = "$env:LOCALAPPDATA\ShopDB"
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
$logFile = Join-Path $logDir 'default-printer.log'
|
||||
function Log([string]$msg) {
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||
}
|
||||
|
||||
if (-not $PrinterName) {
|
||||
foreach ($path in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||
try {
|
||||
if (Test-Path $path) {
|
||||
$value = [string](Get-ItemProperty -Path $path -Name DefaultPrinter -ErrorAction Stop).DefaultPrinter
|
||||
if ($value -and $value.Trim()) { $PrinterName = $value.Trim(); break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PrinterName) {
|
||||
# No default assigned is a legitimate state - a bay with three printers and
|
||||
# no favourite - so leave whatever the user has.
|
||||
Log 'no default assigned in ShopDB; leaving the current one alone'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Windows 10+ overrides any default the moment the user prints elsewhere, unless
|
||||
# this is off. Setting the default without clearing this is a fix that undoes
|
||||
# itself within a day.
|
||||
try {
|
||||
$windowsKey = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows'
|
||||
$managed = (Get-ItemProperty -Path $windowsKey -Name LegacyDefaultPrinterMode -ErrorAction SilentlyContinue).LegacyDefaultPrinterMode
|
||||
if ($managed -ne 1) {
|
||||
Set-ItemProperty -Path $windowsKey -Name LegacyDefaultPrinterMode -Value 1 -Type DWord
|
||||
Log 'turned off "Let Windows manage my default printer"'
|
||||
}
|
||||
} catch {
|
||||
Log "WARN could not turn off Windows-managed defaults: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$queue = Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue
|
||||
if (-not $queue) {
|
||||
# The enforcement cycle creates queues; this runs at logon and may simply be
|
||||
# earlier than the first cycle on a new bay. Next run picks it up.
|
||||
Log "assigned default '$PrinterName' is not installed yet; nothing to do"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$current = (Get-CimInstance -ClassName Win32_Printer -Filter 'Default = True' -ErrorAction SilentlyContinue).Name
|
||||
if ($current -eq $PrinterName) {
|
||||
Log "already default: $PrinterName"
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
$target = Get-CimInstance -ClassName Win32_Printer -Filter ("Name = '{0}'" -f $PrinterName.Replace("'", "''")) -ErrorAction Stop
|
||||
Invoke-CimMethod -InputObject $target -MethodName SetDefaultPrinter -ErrorAction Stop | Out-Null
|
||||
Log "default set: $PrinterName (was '$current')"
|
||||
} catch {
|
||||
Log "ERROR setting the default to '$PrinterName': $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
exit 0
|
||||
144
plugins/printers/client/DEPLOYING-DRIVERS.md
Normal file
144
plugins/printers/client/DEPLOYING-DRIVERS.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Deploying the printer driver set
|
||||
|
||||
The driver set is a package: `Install-ShopdbPrinterDrivers.ps1`, the
|
||||
single-driver worker it wraps, a `drivers.json` naming each driver and where its
|
||||
files are, and the driver packages themselves.
|
||||
|
||||
Staging drivers is deliberately SEPARATE from assigning printers. Drivers are
|
||||
large, change rarely and are identical across a fleet; assignments are small,
|
||||
per-bay and change often. Keeping them apart means creating a queue never waits
|
||||
on a download, and a driver never has to be fetched at the moment someone is
|
||||
trying to print.
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
ShopdbPrinterDrivers\
|
||||
Install-ShopdbPrinterDrivers.ps1 the whole set, manifest driven
|
||||
Install-ShopdbPrinterDriver.ps1 one driver (this does the work)
|
||||
drivers.json what this site deploys
|
||||
drivers\
|
||||
hp_upd_ps\ xerox_gpd\ hp_designjet\ zebra_zt411\ ...
|
||||
```
|
||||
|
||||
`drivers.json` paths may be relative to the package or absolute. A site whose
|
||||
packages already live on a share points at the share and ships only the two
|
||||
scripts and the manifest.
|
||||
|
||||
## GE-Enforce, in the `common` scope
|
||||
|
||||
Every shop-floor PC gets every driver, once. After the first cycle each run is a
|
||||
`Get-PrinterDriver` check per driver and nothing else, so the cost is a few
|
||||
milliseconds, not a re-install.
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "Stage the site's printer drivers. Runs in-cycle because the share is only mounted then. Idempotent: a driver already present is skipped.",
|
||||
"Name": "ShopDB printer drivers",
|
||||
"Type": "PS1",
|
||||
"Script": "scripts/Install-ShopdbPrinterDrivers.ps1",
|
||||
"DetectionMethod": "Always"
|
||||
}
|
||||
```
|
||||
|
||||
**It must be a manifest entry, not its own scheduled task.** The SFLD share is
|
||||
mounted only for the duration of the enforcement cycle; off-cycle the paths
|
||||
simply do not exist and every run logs "package not found" forever.
|
||||
|
||||
## Azure Machine Configuration / DSC
|
||||
|
||||
The script answers a compliance question, which is what makes it a clean `Script`
|
||||
resource: `-TestOnly` reports whether every driver in the manifest is present and
|
||||
exits 0 or 1 without changing anything.
|
||||
|
||||
```powershell
|
||||
Configuration ShopdbPrinterDrivers
|
||||
{
|
||||
Import-DscResource -ModuleName PSDesiredStateConfiguration
|
||||
|
||||
Node localhost
|
||||
{
|
||||
Script PrinterDrivers
|
||||
{
|
||||
GetScript = {
|
||||
@{ Result = (Get-PrinterDriver | Select-Object -ExpandProperty Name) -join ', ' }
|
||||
}
|
||||
TestScript = {
|
||||
$p = Start-Process -FilePath 'powershell.exe' -PassThru -Wait -WindowStyle Hidden `
|
||||
-ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',
|
||||
'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1','-TestOnly'
|
||||
return ($p.ExitCode -eq 0)
|
||||
}
|
||||
SetScript = {
|
||||
Start-Process -FilePath 'powershell.exe' -Wait -WindowStyle Hidden `
|
||||
-ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',
|
||||
'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Deliver the package to `C:\ProgramData\ShopDB\Drivers` however that estate
|
||||
already delivers files - a Win32 app, a File resource, or the imaging step.
|
||||
|
||||
## Intune
|
||||
|
||||
Package the folder as a Win32 app.
|
||||
|
||||
- Install: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-ShopdbPrinterDrivers.ps1`
|
||||
- Detection: a script running the same file with `-TestOnly`, exit 0 = detected
|
||||
- Run as SYSTEM. Adding a printer driver has required administrator rights since
|
||||
the 2021 print hardening, and SYSTEM satisfies it.
|
||||
|
||||
## Why not have the assignment client fetch drivers
|
||||
|
||||
It was considered and rejected. A bay would then download a driver at the moment
|
||||
a printer is assigned, which is the worst time: someone is waiting, the share may
|
||||
be unmounted, and a 48 MB package would be pulled per bay per change. Staging the
|
||||
set in `common` makes assignment a queue creation and nothing more.
|
||||
|
||||
## One driver per package, named exactly
|
||||
|
||||
`drivers.json` carries the driver name as its INF declares it - `Add-PrinterDriver`
|
||||
matches that string and nothing else. The names verified on Windows for the
|
||||
reference site's fleet:
|
||||
|
||||
| driver | covers |
|
||||
|---|---|
|
||||
| `HP Universal Printing PS` | HP office printers |
|
||||
| `Xerox Global Print Driver PCL6` | Xerox office printers |
|
||||
| `HP DesignJet T1700dr V4` | DesignJet plotters (a v4 class driver) |
|
||||
| `ZDesigner ZT411-300dpi ZPL` | Zebra ZT411 labels |
|
||||
| `EPSON TM-C3500` | Epson ColorWorks labels |
|
||||
| `DTC4500e Card Printer` | HID FARGO card printer |
|
||||
|
||||
## The other half: assigning printers
|
||||
|
||||
Staging drivers is only delivery. `Set-ShopdbPrinters.ps1` is what makes a bay's
|
||||
queues match ShopDB, and `Apply-ShopdbDefaultPrinter.ps1` applies the default in
|
||||
the user's context. Two manifest entries, both `DetectionMethod: Always`:
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "Create the queues this bay is assigned. Converges: existing queues are left alone, and nothing is ever removed.",
|
||||
"Name": "ShopDB printers",
|
||||
"Type": "PS1",
|
||||
"Script": "scripts/Set-ShopdbPrinters.ps1",
|
||||
"DetectionMethod": "Always"
|
||||
}
|
||||
```
|
||||
|
||||
The default printer is per-user, so SYSTEM cannot set it for the person logged
|
||||
on. `Set-ShopdbPrinters.ps1` records it in `HKLM:\SOFTWARE\GE\ShopDB`
|
||||
`DefaultPrinter`, and `Apply-ShopdbDefaultPrinter.ps1` runs as the user - at
|
||||
logon, and on a repeat if the site wants drift corrected.
|
||||
|
||||
Order matters on a new bay: drivers, then queues, then the default. Each step is
|
||||
a no-op once satisfied, so running all three every cycle costs a few registry
|
||||
reads.
|
||||
|
||||
Verified end to end on Windows 11 against a live ShopDB: printers assigned to a
|
||||
MACHINE, a PC controlling it and holding no rows of its own, and the bay created
|
||||
both queues with the right universal driver, recorded the default, and set it -
|
||||
then a second run changed nothing.
|
||||
193
plugins/printers/client/Install-ShopdbPrinterDriver.ps1
Normal file
193
plugins/printers/client/Install-ShopdbPrinterDriver.ps1
Normal file
@@ -0,0 +1,193 @@
|
||||
# Install-ShopdbPrinterDriver.ps1
|
||||
#
|
||||
# Stages a printer driver into the Windows Driver Store and makes it available
|
||||
# to the spooler, silently and offline. Deployable as a DSC Script resource, an
|
||||
# Intune platform script, or a GE-Enforce manifest entry - it needs no user, no
|
||||
# network beyond the driver source, and no vendor setup.exe.
|
||||
#
|
||||
# WHY NOT THE VENDOR INSTALLER: HP's and Xerox's universal drivers are ordinary
|
||||
# INF driver packages. pnputil stages them without a UI, which is the only way
|
||||
# this works on a locked bay with nobody logged in. The vendor bundles add a
|
||||
# wizard and a service nobody wants.
|
||||
#
|
||||
# WHY IT IS SILENT: the signing certificate is added to Trusted Publishers first.
|
||||
# Without that, pnputil prompts to trust the publisher and the install stalls
|
||||
# forever behind a dialog no one will ever see. This mirrors the sequence the
|
||||
# printer installer has used in production.
|
||||
#
|
||||
# IDEMPOTENT: if the spooler already has the driver by name, it does nothing.
|
||||
# Safe to run every enforcement cycle.
|
||||
#
|
||||
# DRIVER NAME: -DriverName must be the name the INF declares, verbatim, e.g.
|
||||
# 'HP Universal Printing PCL 6'. A near-miss fails at Add-PrinterDriver with an
|
||||
# unhelpful error, which is why ShopDB stores the name rather than guessing it.
|
||||
#
|
||||
# SHARE PATHS: on a GE-Enforce site the driver source usually lives on the SFLD
|
||||
# share, which is mounted ONLY during the enforcement cycle. Run this as a
|
||||
# manifest entry inside that cycle, never as its own scheduled task - off-cycle
|
||||
# the path is simply absent and this logs "source not reachable" forever.
|
||||
#
|
||||
# Exits 0 always. A driver that cannot be staged is logged, not thrown: a failed
|
||||
# printer must never fail an enforcement run.
|
||||
|
||||
param(
|
||||
# Exact driver name from the INF, e.g. 'Xerox Global Print Driver PCL6'.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriverName,
|
||||
|
||||
# Folder holding the driver package, or a path to a specific .inf.
|
||||
# UNC or local. This is PrinterDriver.location in ShopDB.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Source,
|
||||
|
||||
# Stage every .inf found under Source rather than picking one. Universal
|
||||
# driver packages ship several INFs and the needed one is not always
|
||||
# obvious; staging all of them is cheap and avoids guessing.
|
||||
[switch]$AllInf,
|
||||
|
||||
[int]$TimeoutSec = 600
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$logDir = 'C:\Logs\Shopfloor'
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
$logFile = Join-Path $logDir ('printer-drivers-{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
|
||||
}
|
||||
|
||||
Log "=== Install printer driver: $DriverName ==="
|
||||
|
||||
# Already present: nothing to do. This is the common case on every cycle after
|
||||
# the first, so it is checked before anything touches the share.
|
||||
$existing = Get-PrinterDriver -Name $DriverName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Log "already installed, nothing to do"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Source)) {
|
||||
Log "ERROR source not reachable: $Source"
|
||||
Log " (on a GE-Enforce site, is this running inside the cycle? the share is"
|
||||
Log " mounted only for the duration of the run.)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Collect the INFs to stage.
|
||||
$infs = @()
|
||||
if ((Get-Item $Source).PSIsContainer) {
|
||||
$found = Get-ChildItem -Path $Source -Filter '*.inf' -Recurse -ErrorAction SilentlyContinue
|
||||
if (-not $AllInf) {
|
||||
# Prefer an INF whose name hints at the architecture in use; otherwise
|
||||
# take them all. Staging a surplus INF costs disk, missing one costs a
|
||||
# site visit.
|
||||
$infs = @($found)
|
||||
} else {
|
||||
$infs = @($found)
|
||||
}
|
||||
} elseif ($Source -like '*.inf') {
|
||||
$infs = @(Get-Item $Source)
|
||||
}
|
||||
|
||||
if (-not $infs -or $infs.Count -eq 0) {
|
||||
Log "ERROR no .inf found under $Source"
|
||||
exit 0
|
||||
}
|
||||
Log "found $($infs.Count) inf file(s)"
|
||||
|
||||
# Trust the package's SIGNER FIRST, or pnputil refuses with "The publisher of an
|
||||
# Authenticode(tm) signed catalog has not yet been established as trusted" - and
|
||||
# on a bay with nobody logged in there is no prompt to answer, so the install
|
||||
# simply never happens.
|
||||
#
|
||||
# The certificate is EXTRACTED from the catalog and added to Trusted Publishers.
|
||||
# Adding the .cat file itself with certutil -addstore is not the same thing: it
|
||||
# stores the catalog, not the publisher, and whether that satisfies pnputil
|
||||
# varies by vendor. It worked for one universal driver and failed for another,
|
||||
# which is a coin toss, not a mechanism.
|
||||
#
|
||||
# Every catalog under the source is trusted, not just the ones beside the first
|
||||
# INF: a universal driver package holds several, and the one that matters is not
|
||||
# predictably the first.
|
||||
$cats = @(Get-ChildItem -Path $Source -Filter '*.cat' -Recurse -ErrorAction SilentlyContinue)
|
||||
$trusted = 0
|
||||
if ($cats.Count -gt 0) {
|
||||
try {
|
||||
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||
'TrustedPublisher', 'LocalMachine')
|
||||
$store.Open('ReadWrite')
|
||||
foreach ($cat in $cats) {
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $cat.FullName -ErrorAction Stop
|
||||
if ($sig -and $sig.SignerCertificate) {
|
||||
$store.Add($sig.SignerCertificate)
|
||||
$trusted++
|
||||
} else {
|
||||
Log "WARN no signer certificate on $($cat.Name)"
|
||||
}
|
||||
} catch {
|
||||
Log "WARN could not trust $($cat.Name): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
$store.Close()
|
||||
} catch {
|
||||
Log "WARN could not open the Trusted Publishers store: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
Log "trusted $trusted of $($cats.Count) catalog(s)"
|
||||
|
||||
# Stage into the Driver Store. Deliberately WITHOUT /install: that runs a PnP
|
||||
# device-match pass which is pointless for a network printer and slow across a
|
||||
# universal driver's thousands of models. Add-PrinterDriver binds it afterwards.
|
||||
$staged = $false
|
||||
foreach ($inf in $infs) {
|
||||
$null = & pnputil.exe /add-driver $inf.FullName 2>&1
|
||||
# 259 = no more data (nothing new to add), 3010 = success, reboot queued.
|
||||
if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 259 -or $LASTEXITCODE -eq 3010) {
|
||||
$staged = $true
|
||||
} else {
|
||||
Log "WARN pnputil exit $LASTEXITCODE for $($inf.Name)"
|
||||
}
|
||||
}
|
||||
if (-not $staged) {
|
||||
Log "ERROR nothing staged from $Source"
|
||||
exit 0
|
||||
}
|
||||
Log "staged into the driver store"
|
||||
|
||||
# Make it known to the spooler under the name ShopDB holds.
|
||||
try {
|
||||
Add-PrinterDriver -Name $DriverName -ErrorAction Stop
|
||||
Log "installed: $DriverName"
|
||||
} catch {
|
||||
Log "ERROR Add-PrinterDriver failed for '$DriverName': $($_.Exception.Message)"
|
||||
# Display names live in the INF's [Strings] section as token="Some Name",
|
||||
# referenced elsewhere as %token%. Reading the model lines instead just
|
||||
# reports the manufacturer, which is no help to whoever has to fix this.
|
||||
Log " the name must match the INF verbatim. Names these packages offer:"
|
||||
$offered = @()
|
||||
foreach ($inf in $infs) {
|
||||
$hits = Select-String -Path $inf.FullName -Encoding unicode `
|
||||
-Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue
|
||||
if (-not $hits) {
|
||||
$hits = Select-String -Path $inf.FullName `
|
||||
-Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue
|
||||
}
|
||||
foreach ($h in $hits) {
|
||||
$value = $h.Matches[0].Groups[1].Value
|
||||
# A driver name has a space in it; version strings and paths do not.
|
||||
if ($value -match '^[A-Za-z].*\s') { $offered += $value }
|
||||
}
|
||||
}
|
||||
foreach ($name in ($offered | Sort-Object -Unique | Select-Object -First 10)) {
|
||||
Log " $name"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
exit 0
|
||||
126
plugins/printers/client/Install-ShopdbPrinterDrivers.ps1
Normal file
126
plugins/printers/client/Install-ShopdbPrinterDrivers.ps1
Normal file
@@ -0,0 +1,126 @@
|
||||
# Install-ShopdbPrinterDrivers.ps1
|
||||
#
|
||||
# Installs a SITE'S WHOLE DRIVER SET from a manifest, so a bay ends up with every
|
||||
# printer driver it might need in one converging run. Wraps
|
||||
# Install-ShopdbPrinterDriver.ps1, which does one driver.
|
||||
#
|
||||
# DESIGNED FOR DSC / Intune / GE-Enforce. It declares state rather than
|
||||
# performing an install: a driver already present is skipped, so this is safe to
|
||||
# run on a schedule and cheap when there is nothing to do. That is what lets a
|
||||
# DSC Script resource call it from TestScript as well as SetScript.
|
||||
#
|
||||
# THE MANIFEST, not arguments, is the contract. drivers.json lists each driver by
|
||||
# the name its INF declares - what Add-PrinterDriver matches on, verbatim - and
|
||||
# where its package lives. Paths are relative to this script, or absolute (a UNC
|
||||
# path on a site's share is normal).
|
||||
#
|
||||
# EXIT CODE: 0 when every driver in the manifest is present at the end, 1 when
|
||||
# one or more could not be installed. DSC needs a real answer here, unlike the
|
||||
# single-driver script which never fails an enforcement run. The per-driver log
|
||||
# says which and why.
|
||||
#
|
||||
# SHARE PATHS: on a GE-Enforce site the packages usually live on the SFLD share,
|
||||
# which is mounted ONLY during the enforcement cycle. Run this as a manifest
|
||||
# entry inside that cycle, not as its own scheduled task.
|
||||
|
||||
param(
|
||||
# Defaults to drivers.json beside this script.
|
||||
[string]$Manifest = '',
|
||||
|
||||
# Install only these driver names; everything else in the manifest is
|
||||
# ignored. For a bay that needs one driver out of a site-wide set.
|
||||
[string[]]$Only = @(),
|
||||
|
||||
# Report what is missing and change nothing. This is what a DSC TestScript
|
||||
# calls: exit 0 means compliant.
|
||||
[switch]$TestOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
if (-not $Manifest) { $Manifest = Join-Path $here 'drivers.json' }
|
||||
|
||||
$logDir = 'C:\Logs\Shopfloor'
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
$logFile = Join-Path $logDir ('printer-drivers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||
function Log([string]$msg) {
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
"$ts [set] $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Manifest)) {
|
||||
Log "ERROR manifest not found: $Manifest"
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Get-Content -Raw -Path $Manifest | ConvertFrom-Json
|
||||
} catch {
|
||||
Log "ERROR manifest is not valid JSON: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$wanted = @($config.drivers)
|
||||
if ($Only.Count -gt 0) {
|
||||
$wanted = @($wanted | Where-Object { $Only -contains $_.drivername })
|
||||
}
|
||||
if ($wanted.Count -eq 0) {
|
||||
Log "nothing to do: the manifest selects no drivers"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$single = Join-Path $here 'Install-ShopdbPrinterDriver.ps1'
|
||||
if (-not (Test-Path $single)) {
|
||||
Log "ERROR Install-ShopdbPrinterDriver.ps1 is not beside this script"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$missing = @()
|
||||
foreach ($driver in $wanted) {
|
||||
$name = $driver.drivername
|
||||
if (-not $name) { continue }
|
||||
|
||||
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
|
||||
Log "present: $name"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($TestOnly) {
|
||||
Log "MISSING: $name"
|
||||
$missing += $name
|
||||
continue
|
||||
}
|
||||
|
||||
# Relative paths are resolved against the package, so the whole thing can be
|
||||
# copied anywhere - a share, C:\ProgramData, an Intune staging folder - and
|
||||
# still find its own payloads.
|
||||
$path = $driver.path
|
||||
if ($path -and -not [System.IO.Path]::IsPathRooted($path)) {
|
||||
$path = Join-Path $here $path
|
||||
}
|
||||
if (-not $path -or -not (Test-Path $path)) {
|
||||
Log "ERROR package not found for '$name': $path"
|
||||
$missing += $name
|
||||
continue
|
||||
}
|
||||
|
||||
Log "installing: $name"
|
||||
& $single -DriverName $name -Source $path | Out-Null
|
||||
|
||||
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
|
||||
Log "installed: $name"
|
||||
} else {
|
||||
Log "FAILED: $name (see the per-driver lines above)"
|
||||
$missing += $name
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
Log ("not present: {0}" -f ($missing -join ', '))
|
||||
exit 1
|
||||
}
|
||||
Log "all $($wanted.Count) driver(s) present"
|
||||
exit 0
|
||||
166
plugins/printers/client/Set-ShopdbPrinters.ps1
Normal file
166
plugins/printers/client/Set-ShopdbPrinters.ps1
Normal file
@@ -0,0 +1,166 @@
|
||||
# Set-ShopdbPrinters.ps1
|
||||
#
|
||||
# Makes this PC's printers match what ShopDB says the bay should have. Asks
|
||||
# GET /api/printers/for-host/<hostname> and creates any queue that is missing.
|
||||
#
|
||||
# WHY THE ASSIGNMENT IS NOT ON THIS PC: it is on the MACHINE, and reaches
|
||||
# whichever PC controls it. A reimaged or swapped box inherits the bay's printers
|
||||
# with nothing saved off the old one - the asset register is the backup.
|
||||
#
|
||||
# CONVERGES, does not install. A queue that already exists is left alone, so this
|
||||
# is cheap to run every enforcement cycle and safe to run twice.
|
||||
#
|
||||
# NEVER REMOVES A QUEUE. If a printer disappears from the response - because the
|
||||
# API had a bad minute, or someone unassigned it - the bay keeps printing. Taking
|
||||
# printers away from a working bay because of a transient error is the one
|
||||
# failure this must not have.
|
||||
#
|
||||
# DRIVERS ARE NOT FETCHED HERE. Install-ShopdbPrinterDrivers.ps1 stages the site's
|
||||
# set in the common scope, once per bay. A queue is created against a driver that
|
||||
# is already present; if it is not, that is logged and the printer is skipped,
|
||||
# because downloading 48 MB while somebody waits to print is the wrong moment.
|
||||
#
|
||||
# THE DEFAULT PRINTER IS PER USER. This runs as SYSTEM and cannot set it for the
|
||||
# logged-on person, so it records the desired default in HKLM and leaves applying
|
||||
# it to a logon task. Without that, SYSTEM would set a default nobody sees.
|
||||
#
|
||||
# Exits 0 always: a printer problem must not fail an enforcement run.
|
||||
|
||||
param(
|
||||
# ShopDB base URL. Empty resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
|
||||
# written by Install-GEEnforce.ps1 and already present wherever this runs.
|
||||
[string]$BaseUrl = '',
|
||||
|
||||
# Defaults to this machine's name, which is what the collector upserts by.
|
||||
[string]$Hostname = $env:COMPUTERNAME,
|
||||
|
||||
[int]$TimeoutSec = 30,
|
||||
|
||||
# Report what would change and touch nothing.
|
||||
[switch]$WhatIfOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
[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 ('printers-{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
|
||||
}
|
||||
|
||||
$REGPATH = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
|
||||
if (-not $BaseUrl) {
|
||||
foreach ($path in @($REGPATH, 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||
try {
|
||||
if (Test-Path $path) {
|
||||
$value = [string](Get-ItemProperty -Path $path -Name BaseUrl -ErrorAction Stop).BaseUrl
|
||||
if ($value -and $value.Trim()) { $BaseUrl = $value.Trim(); break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $BaseUrl) {
|
||||
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -BaseUrl). Skipping.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
Log "=== Set printers for $Hostname ==="
|
||||
|
||||
$url = $BaseUrl.TrimEnd('/') + '/api/printers/for-host/' + [uri]::EscapeDataString($Hostname)
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec $TimeoutSec
|
||||
} catch {
|
||||
# An unreachable server means "no information", not "no printers". Changing
|
||||
# nothing is the only safe response.
|
||||
Log "ERROR could not read $url : $($_.Exception.Message)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$payload = $response.data
|
||||
if ($null -eq $payload) { $payload = $response }
|
||||
$wanted = @($payload.printers)
|
||||
$defaultid = $payload.defaultprinterid
|
||||
|
||||
if ($wanted.Count -eq 0) {
|
||||
Log 'nothing assigned to this host'
|
||||
exit 0
|
||||
}
|
||||
Log "assigned: $($wanted.Count) printer(s)"
|
||||
|
||||
$existing = @{}
|
||||
foreach ($queue in (Get-Printer -ErrorAction SilentlyContinue)) {
|
||||
$existing[$queue.Name] = $queue
|
||||
}
|
||||
|
||||
$defaultname = ''
|
||||
foreach ($printer in $wanted) {
|
||||
$name = $printer.queuename
|
||||
if (-not $name) { continue }
|
||||
if ($printer.printerid -eq $defaultid) { $defaultname = $name }
|
||||
|
||||
if ($existing.ContainsKey($name)) {
|
||||
Log "present: $name"
|
||||
continue
|
||||
}
|
||||
|
||||
$address = $printer.hostname
|
||||
if (-not $address) { $address = $printer.ipaddress }
|
||||
if (-not $address) {
|
||||
Log "SKIP $name : no hostname or IP to point a port at"
|
||||
continue
|
||||
}
|
||||
|
||||
$drivername = $printer.drivername
|
||||
if (-not $drivername) {
|
||||
Log "SKIP $name : ShopDB has no driver name for it"
|
||||
continue
|
||||
}
|
||||
if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) {
|
||||
# Deliberately not fetched here - see the header.
|
||||
Log "SKIP $name : driver '$drivername' is not staged on this PC"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($WhatIfOnly) {
|
||||
Log "WOULD create: $name -> $address ($drivername)"
|
||||
continue
|
||||
}
|
||||
|
||||
$portname = 'IP_' + $address
|
||||
try {
|
||||
if (-not (Get-PrinterPort -Name $portname -ErrorAction SilentlyContinue)) {
|
||||
Add-PrinterPort -Name $portname -PrinterHostAddress $address -ErrorAction Stop
|
||||
Log "port: $portname"
|
||||
}
|
||||
Add-Printer -Name $name -DriverName $drivername -PortName $portname -ErrorAction Stop
|
||||
Log "created: $name -> $address ($drivername)"
|
||||
} catch {
|
||||
Log "ERROR creating ${name}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# The default is recorded, not applied: this process is SYSTEM and the setting
|
||||
# is per user. Apply-ShopdbDefaultPrinter.ps1 reads it at logon.
|
||||
if ($defaultname) {
|
||||
if ($WhatIfOnly) {
|
||||
Log "WOULD record default: $defaultname"
|
||||
} else {
|
||||
try {
|
||||
if (-not (Test-Path $REGPATH)) { New-Item -Path $REGPATH -Force | Out-Null }
|
||||
Set-ItemProperty -Path $REGPATH -Name DefaultPrinter -Value $defaultname
|
||||
Log "default recorded for the logon task: $defaultname"
|
||||
} catch {
|
||||
Log "ERROR recording the default: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log 'no default assigned'
|
||||
}
|
||||
|
||||
exit 0
|
||||
35
plugins/printers/client/drivers.example.json
Normal file
35
plugins/printers/client/drivers.example.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"_comment": "Driver set for a site. Each entry names a driver EXACTLY as its INF declares it (what Add-PrinterDriver matches on) and where its package lives, relative to the package root or as an absolute UNC path. Copy to drivers.json and edit for the site.",
|
||||
"drivers": [
|
||||
{
|
||||
"drivername": "HP Universal Printing PS",
|
||||
"path": "drivers/hp_upd_ps",
|
||||
"covers": "HP office printers (universal)"
|
||||
},
|
||||
{
|
||||
"drivername": "Xerox Global Print Driver PCL6",
|
||||
"path": "drivers/xerox_gpd",
|
||||
"covers": "Xerox office printers (universal)"
|
||||
},
|
||||
{
|
||||
"drivername": "HP DesignJet T1700dr V4",
|
||||
"path": "drivers/hp_designjet",
|
||||
"covers": "DesignJet T1700 / T1700dr plotters"
|
||||
},
|
||||
{
|
||||
"drivername": "ZDesigner ZT411-300dpi ZPL",
|
||||
"path": "drivers/zebra_zt411",
|
||||
"covers": "Zebra ZT411 label printers"
|
||||
},
|
||||
{
|
||||
"drivername": "EPSON TM-C3500",
|
||||
"path": "drivers/epson_tmc3500",
|
||||
"covers": "Epson ColorWorks C3500 label printers"
|
||||
},
|
||||
{
|
||||
"drivername": "DTC4500e Card Printer",
|
||||
"path": "drivers/hid_dtc4500e",
|
||||
"covers": "HID FARGO DTC4500e card printer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -169,6 +169,7 @@
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:levelid="printer.levelid"
|
||||
:machineName="printer.name || printer.assetnumber"
|
||||
>
|
||||
<span class="location-link">View on Map</span>
|
||||
|
||||
@@ -43,14 +43,13 @@
|
||||
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
||||
>
|
||||
<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">
|
||||
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -67,6 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import { renderQrDataUrl } from '@/utils/codes'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const printers = ref([])
|
||||
const selectedPrinters = ref([])
|
||||
@@ -91,6 +91,7 @@ const pages = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
// listAll: perpage is clamped to 100, and a batch sheet must cover every
|
||||
// printer, not the first page of them.
|
||||
printers.value = await printersApi.listAll()
|
||||
@@ -129,6 +130,29 @@ async function generateQRCodes() {
|
||||
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) {
|
||||
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-inner { text-align: left; }
|
||||
.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; }
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -24,14 +24,13 @@
|
||||
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
|
||||
>
|
||||
<template v-if="pos === parseInt(position)">
|
||||
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
|
||||
<div class="csf-name">{{ labelName }}</div>
|
||||
<div class="qr-container">
|
||||
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ printer.assetnumber }}</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -49,6 +48,7 @@ import { useRoute } from 'vue-router'
|
||||
import { printersApi } from '@/api'
|
||||
import { renderQrDataUrl } from '@/utils/codes'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
@@ -58,6 +58,18 @@ const position = ref('1')
|
||||
// in print output, images print every time.
|
||||
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(() => {
|
||||
// Check direct ipaddress field first (from list API)
|
||||
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
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
const response = await printersApi.get(route.params.id)
|
||||
printer.value = response.data.data
|
||||
} catch (error) {
|
||||
@@ -169,7 +194,7 @@ function print() {
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.info-inner { text-align: left; }
|
||||
.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 {
|
||||
/* Force rendered images/colors to print even when "Background graphics" is
|
||||
|
||||
@@ -1,378 +1,379 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading supply data...</div>
|
||||
|
||||
<div v-else-if="error" class="card">
|
||||
<p class="text-danger">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Bar -->
|
||||
<div class="summary-bar">
|
||||
<div class="summary-stat card">
|
||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||
<div class="stat-label">Printers Checked</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-low">
|
||||
<div class="stat-value">{{ summary.low }}</div>
|
||||
<div class="stat-label">Low Supply</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-critical">
|
||||
<div class="stat-value">{{ summary.critical }}</div>
|
||||
<div class="stat-label">Critical Supply</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filters">
|
||||
<button
|
||||
v-for="f in filterOptions"
|
||||
:key="f.value"
|
||||
class="btn"
|
||||
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="filter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printers Table -->
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table v-if="filteredPrinters.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<td>
|
||||
<!-- Coordinates give the name the same floor-plan preview the
|
||||
printer's own page uses; it replaces the Location column. -->
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:machineName="printer.printername || printer.assetnumber"
|
||||
>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</LocationMapTooltip>
|
||||
<router-link v-else :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Opens the printer's own web page. New tab: the report is a
|
||||
worklist, and losing your place in it to visit one printer
|
||||
means finding your row again. -->
|
||||
<a
|
||||
v-if="fqdnFor(printer)"
|
||||
:href="`http://${fqdnFor(printer)}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="printer.ipaddress"
|
||||
>{{ fqdnFor(printer) }}</a>
|
||||
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
||||
</td>
|
||||
<td class="supplies-cell">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
:key="idx"
|
||||
class="supply-row"
|
||||
>
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<div class="supply-bar-track">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="'supply-' + supply.status"
|
||||
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</span>
|
||||
<span class="supply-parts">
|
||||
<span
|
||||
v-for="part in supply.partnumbers"
|
||||
:key="part.partnumber"
|
||||
class="part-chip"
|
||||
:title="partTooltip(part)"
|
||||
>{{ part.partnumber }}</span>
|
||||
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
||||
class="part-none" title="No part number on file for this model and color">-</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'hostname', label: 'Hostname' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'partnumber', label: 'Part Number' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const printers = ref([])
|
||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||
const filter = ref('all')
|
||||
|
||||
const filterOptions = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ 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.
|
||||
const hostnameTemplate = ref('')
|
||||
|
||||
function fqdnFor(printer) {
|
||||
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.
|
||||
function partTooltip(part) {
|
||||
const bits = [part.marketingname, part.capacitytier]
|
||||
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.
|
||||
function reportRows() {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
const parts = supply.partnumbers && supply.partnumbers.length
|
||||
? supply.partnumbers.map(p => p.partnumber)
|
||||
: ['']
|
||||
for (const partnumber of parts) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
partnumber: partnumber,
|
||||
level: supply.level,
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
if (filter.value === 'all') return printers.value
|
||||
return printers.value.filter(p =>
|
||||
p.supplies.some(s => s.status === filter.value)
|
||||
)
|
||||
})
|
||||
|
||||
// Emailed table, honoring the active filter.
|
||||
const emailRows = computed(() =>
|
||||
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||
)
|
||||
|
||||
function exportCSV() {
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||
const rows = [header]
|
||||
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')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'toner_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
const response = await printersApi.lowSupplies()
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||
} catch (err) {
|
||||
console.error('Error loading toner report:', err)
|
||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-low .stat-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.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
|
||||
fixed 120px column, which hid the very thing being reordered; a max-content
|
||||
column sizes to the longest name instead, and the columns still line up
|
||||
across the rows of one printer. */
|
||||
.supplies-cell {
|
||||
min-width: 460px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supply-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.supply-bar-track {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.supply-ok {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-low {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.supply-critical {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supply-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.part-chip {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.part-none {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.supply-text-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.supply-text-low {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.supply-text-critical {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading supply data...</div>
|
||||
|
||||
<div v-else-if="error" class="card">
|
||||
<p class="text-danger">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Bar -->
|
||||
<div class="summary-bar">
|
||||
<div class="summary-stat card">
|
||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||
<div class="stat-label">Printers Checked</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-low">
|
||||
<div class="stat-value">{{ summary.low }}</div>
|
||||
<div class="stat-label">Low Supply</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-critical">
|
||||
<div class="stat-value">{{ summary.critical }}</div>
|
||||
<div class="stat-label">Critical Supply</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filters">
|
||||
<button
|
||||
v-for="f in filterOptions"
|
||||
:key="f.value"
|
||||
class="btn"
|
||||
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="filter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printers Table -->
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table v-if="filteredPrinters.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<td>
|
||||
<!-- Coordinates give the name the same floor-plan preview the
|
||||
printer's own page uses; it replaces the Location column. -->
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:levelid="printer.levelid"
|
||||
:machineName="printer.printername || printer.assetnumber"
|
||||
>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</LocationMapTooltip>
|
||||
<router-link v-else :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Opens the printer's own web page. New tab: the report is a
|
||||
worklist, and losing your place in it to visit one printer
|
||||
means finding your row again. -->
|
||||
<a
|
||||
v-if="fqdnFor(printer)"
|
||||
:href="`http://${fqdnFor(printer)}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="printer.ipaddress"
|
||||
>{{ fqdnFor(printer) }}</a>
|
||||
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
||||
</td>
|
||||
<td class="supplies-cell">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
:key="idx"
|
||||
class="supply-row"
|
||||
>
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<div class="supply-bar-track">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="'supply-' + supply.status"
|
||||
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</span>
|
||||
<span class="supply-parts">
|
||||
<span
|
||||
v-for="part in supply.partnumbers"
|
||||
:key="part.partnumber"
|
||||
class="part-chip"
|
||||
:title="partTooltip(part)"
|
||||
>{{ part.partnumber }}</span>
|
||||
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
||||
class="part-none" title="No part number on file for this model and color">-</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'hostname', label: 'Hostname' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'partnumber', label: 'Part Number' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const printers = ref([])
|
||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||
const filter = ref('all')
|
||||
|
||||
const filterOptions = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ 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.
|
||||
const hostnameTemplate = ref('')
|
||||
|
||||
function fqdnFor(printer) {
|
||||
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.
|
||||
function partTooltip(part) {
|
||||
const bits = [part.marketingname, part.capacitytier]
|
||||
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.
|
||||
function reportRows() {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
const parts = supply.partnumbers && supply.partnumbers.length
|
||||
? supply.partnumbers.map(p => p.partnumber)
|
||||
: ['']
|
||||
for (const partnumber of parts) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
partnumber: partnumber,
|
||||
level: supply.level,
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
if (filter.value === 'all') return printers.value
|
||||
return printers.value.filter(p =>
|
||||
p.supplies.some(s => s.status === filter.value)
|
||||
)
|
||||
})
|
||||
|
||||
// Emailed table, honoring the active filter.
|
||||
const emailRows = computed(() =>
|
||||
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||
)
|
||||
|
||||
function exportCSV() {
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||
const rows = [header]
|
||||
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')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'toner_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
const response = await printersApi.lowSupplies()
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||
} catch (err) {
|
||||
console.error('Error loading toner report:', err)
|
||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-low .stat-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.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
|
||||
fixed 120px column, which hid the very thing being reordered; a max-content
|
||||
column sizes to the longest name instead, and the columns still line up
|
||||
across the rows of one printer. */
|
||||
.supplies-cell {
|
||||
min-width: 460px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supply-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.supply-bar-track {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.supply-ok {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-low {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.supply-critical {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supply-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.part-chip {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.part-none {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.supply-text-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.supply-text-low {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.supply-text-critical {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
plugins/printers/migrations/versions/0003_drivername.py
Normal file
43
plugins/printers/migrations/versions/0003_drivername.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Add drivername to printerdrivers (exact INF driver name).
|
||||
|
||||
`location` points at the driver package; `name` is what a human calls it.
|
||||
Add-PrinterDriver needs neither - it needs the driver name exactly as the INF
|
||||
declares it ('HP Universal Printing PCL 6'), which nothing in the row carried.
|
||||
Nullable: existing rows have no INF name until someone types it in.
|
||||
|
||||
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
|
||||
column already exists (e.g. a test DB built by db.create_all() from the model).
|
||||
|
||||
Revision ID: printers0003drivername
|
||||
Revises: printers0002supplyalerts
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printers0003drivername'
|
||||
down_revision = 'printers0002supplyalerts'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerdrivers' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||
if 'drivername' not in columns:
|
||||
op.add_column('printerdrivers',
|
||||
sa.Column('drivername', sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerdrivers' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||
if 'drivername' in columns:
|
||||
op.drop_column('printerdrivers', 'drivername')
|
||||
44
plugins/printers/migrations/versions/0004_drivervendor.py
Normal file
44
plugins/printers/migrations/versions/0004_drivervendor.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Give a printer driver a vendor, so one row can serve a whole make.
|
||||
|
||||
HP's and Xerox's universal drivers cover 41 of the reference site's 44 printers
|
||||
between them, but a driver could only be bound to ONE modelnumberid - so covering
|
||||
them meant 21 near-duplicate rows all pointing at the same package, a table
|
||||
nobody would keep true. A driver with no model and a vendor now serves every
|
||||
printer of that make, and a per-model row still wins where one genuinely differs
|
||||
(a plotter, a card printer, a label printer).
|
||||
|
||||
Nullable and guarded: a re-run is a no-op, and existing rows keep working
|
||||
unchanged because model matching is still tried first.
|
||||
|
||||
Revision ID: printers0004drivervendor
|
||||
Revises: printers0003drivername
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'printers0004drivervendor'
|
||||
down_revision = 'printers0003drivername'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||
if 'vendorid' not in columns:
|
||||
op.add_column('printerdrivers', sa.Column('vendorid', sa.Integer(), nullable=True))
|
||||
# No FK constraint: printerdrivers is a plugin table and vendors is core.
|
||||
# ADR-008 keeps plugin chains from writing constraints across that line,
|
||||
# and the resolver treats a vendor that no longer exists as no match.
|
||||
op.create_index('idx_printerdriver_vendor', 'printerdrivers', ['vendorid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||
if 'vendorid' in columns:
|
||||
op.drop_index('idx_printerdriver_vendor', table_name='printerdrivers')
|
||||
op.drop_column('printerdrivers', 'vendorid')
|
||||
@@ -11,6 +11,15 @@ class PrinterDriver(db.Model):
|
||||
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
|
||||
location = db.Column(db.String(500), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
# Exact driver name as the INF declares it: Add-PrinterDriver matches on
|
||||
# this string, not on `name`, which is ours to choose
|
||||
drivername = db.Column(db.String(255))
|
||||
# Optional: attach a driver to a whole VENDOR rather than one model. A
|
||||
# universal driver (HP UPD, Xerox GPD) serves every printer of that make, and
|
||||
# binding it to one model would mean a near-duplicate row per model.
|
||||
# Model wins over vendor when both match - see _printer_driver.
|
||||
vendorid = db.Column(db.Integer, nullable=True, index=True)
|
||||
|
||||
# Optional: attach a driver to a specific printer model
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
@@ -27,6 +36,8 @@ class PrinterDriver(db.Model):
|
||||
'name': self.name,
|
||||
'location': self.location,
|
||||
'description': self.description,
|
||||
'drivername': self.drivername,
|
||||
'vendorid': self.vendorid,
|
||||
'modelnumberid': self.modelnumberid,
|
||||
'modelname': self.model.modelnumber if self.model else None,
|
||||
'isactive': bool(self.isactive),
|
||||
|
||||
495
plugins/slides/client/EventSaver.cs
Normal file
495
plugins/slides/client/EventSaver.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
19
plugins/slides/client/EventSaver.ini
Normal file
19
plugins/slides/client/EventSaver.ini
Normal 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
|
||||
@@ -6,6 +6,7 @@
|
||||
v-if="machine && hasPosition"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:levelid="machine.levelid"
|
||||
:machineName="machine.machinenumber || machine.name || ''"
|
||||
>
|
||||
<span class="mmc-chip" :title="hoverTitle">
|
||||
|
||||
@@ -169,21 +169,44 @@ fi
|
||||
# whether all of them travel with their level, because reading them by eye is
|
||||
# how the twentieth gets missed.
|
||||
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
|
||||
POSITION_FILES=$(grep -rln "'mapx':" --include='*.py' shopdb/ plugins/ 2>/dev/null \
|
||||
| grep -v '/tests\?/' || true)
|
||||
# PER OCCURRENCE, not per file. The file-level form passed a module that emitted
|
||||
# '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=""
|
||||
for candidate in $POSITION_FILES; do
|
||||
if ! grep -q "'levelid'" "$candidate"; then
|
||||
MISSING_LEVEL="$MISSING_LEVEL$candidate"$'\n'
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
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
|
||||
done
|
||||
done <<EOF
|
||||
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
|
||||
EOF
|
||||
if [ -n "$MISSING_LEVEL" ]; then
|
||||
echo "FAIL: these emit 'mapx' but never 'levelid' - a position with no level"
|
||||
echo " cannot be rendered on the right drawing:"
|
||||
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
|
||||
echo " level cannot be drawn on the right floor plan (ADR-017):"
|
||||
echo "$MISSING_LEVEL" | sed 's/^/ /'
|
||||
VIOLATIONS=$((VIOLATIONS + 1))
|
||||
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
|
||||
# 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.
|
||||
|
||||
@@ -100,6 +100,22 @@ class Harness:
|
||||
self.ids = IdMap(idmap_path or default)
|
||||
self.source = Source()
|
||||
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):
|
||||
import logging
|
||||
|
||||
@@ -287,6 +287,7 @@ def stage_assets(h):
|
||||
'serialnumber': (m['serialnumber'] or '').strip() or None,
|
||||
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
||||
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
||||
'levelid': h.defaultlevelid,
|
||||
'notes': m['machinenotes'],
|
||||
'dateadded': str(m['dateadded']) if m['dateadded'] else None,
|
||||
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
|
||||
@@ -345,6 +346,7 @@ def stage_printers(h):
|
||||
'iscsf': _truthy_bit(p['iscsf']),
|
||||
'installpath': p['installpath'], 'pin': p['printerpin'],
|
||||
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
|
||||
'levelid': h.defaultlevelid,
|
||||
'modelnumberid': h.ids.get('model', p['modelid']),
|
||||
'locationid': h.ids.get('location', p['machineid']),
|
||||
}
|
||||
@@ -386,7 +388,8 @@ def stage_metrology(h):
|
||||
'name': (f"{pcname} {toolname}").strip() or toolname,
|
||||
'measuringtooltypeid': typeid,
|
||||
'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):
|
||||
continue
|
||||
tool_assetid = _id_of(data, 'assetid')
|
||||
|
||||
@@ -63,7 +63,7 @@ __contract_version__ = '0.20.0'
|
||||
# plugin-contract version above are distinct series with independent
|
||||
# bump rules. Not part of the shopdb.api contract surface, so it is
|
||||
# not re-exported there.
|
||||
__version__ = '0.11.2'
|
||||
__version__ = '0.11.3'
|
||||
|
||||
|
||||
def create_app(config_name: str = None) -> Flask:
|
||||
@@ -89,6 +89,13 @@ def create_app(config_name: str = None) -> Flask:
|
||||
|
||||
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
|
||||
app.config.from_pyfile('config.py', silent=True)
|
||||
|
||||
|
||||
@@ -516,12 +516,33 @@ def seed_reference_data():
|
||||
for at in adr_types:
|
||||
if not _lookup_binary(at['relationshiptype']):
|
||||
db.session.add(RelationshipType(**at))
|
||||
|
||||
# Printer assignment edges. usesprinter = "this printer is installed here",
|
||||
# defaultprinter = which of them is the default. Assignment lives on the
|
||||
# MACHINE asset and reaches whichever PC controls it through the controls
|
||||
# rail seeded below, so a reimaged PC gets its printers back with no
|
||||
# restore step. Created before the rails: _seed_propagation no-ops in
|
||||
# silence when either type is missing.
|
||||
printer_types = [
|
||||
{'relationshiptype': 'usesprinter',
|
||||
'description': 'Asset to a printer installed on it (ADR-001)',
|
||||
'isdirectional': True},
|
||||
{'relationshiptype': 'defaultprinter',
|
||||
'description': 'PC to its default printer (installer preselect, ADR-001)',
|
||||
'isdirectional': True},
|
||||
]
|
||||
for pt in printer_types:
|
||||
if not _lookup_binary(pt['relationshiptype']):
|
||||
db.session.add(RelationshipType(**pt))
|
||||
db.session.flush()
|
||||
|
||||
# Seed `controls` propagation rails as M:N rows. controls -> partof
|
||||
# (declared; directional rail, not consumed yet) and controls -> Dualpath
|
||||
# (consumed; a dual-bay pair shares one controller so both bays carry
|
||||
# controls). Idempotent, resolved by name, skipped if a type is missing.
|
||||
# Seed propagation rails as M:N rows. controls -> partof (declared;
|
||||
# directional rail, not consumed yet) and controls -> Dualpath (consumed;
|
||||
# a dual-bay pair shares one controller so both bays carry controls).
|
||||
# The two printer rails are READ-TIME only: the create-time fan-out skips
|
||||
# directional through-types, and controls is directional, so a PC's own
|
||||
# rows keep beating what it inherits from the machine it controls.
|
||||
# Idempotent, resolved by name, skipped if a type is missing.
|
||||
from shopdb.core.models.relationship import RelationshipTypePropagation
|
||||
|
||||
def _seed_propagation(sourcename, throughname):
|
||||
@@ -541,15 +562,8 @@ def seed_reference_data():
|
||||
|
||||
_seed_propagation('controls', 'partof')
|
||||
_seed_propagation('controls', 'Dualpath')
|
||||
|
||||
# Default-printer link: a PC asset -> its default printer asset. Read by the
|
||||
# printer-installer endpoint (parity with classic apipcdefaultprinter.asp).
|
||||
# Attribute-style edge, not a position rail, so no propagation.
|
||||
if not _lookup_binary('defaultprinter'):
|
||||
db.session.add(RelationshipType(
|
||||
relationshiptype='defaultprinter',
|
||||
description='PC to its default printer (installer preselect, ADR-001)'
|
||||
))
|
||||
_seed_propagation('usesprinter', 'controls')
|
||||
_seed_propagation('defaultprinter', 'controls')
|
||||
|
||||
db.session.commit()
|
||||
click.echo(click.style("Reference data seeded.", fg='green'))
|
||||
|
||||
@@ -53,8 +53,9 @@ EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
|
||||
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||
# baseline.
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
|
||||
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
|
||||
# printers adds the printersupplyalerts crossing-state table on top of its
|
||||
# anchor, then the exact INF driver name Add-PrinterDriver needs.
|
||||
EXPECTED_HEAD_REVISION['printers'] = 'printers0004drivervendor'
|
||||
# machines (renamed from equipment) keeps its original anchor id and adds the
|
||||
# rename revision on top, so its head is not the f-string default.
|
||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
|
||||
122
tests/test_plugins/test_collector_client_script.py
Normal file
122
tests/test_plugins/test_collector_client_script.py
Normal 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
|
||||
342
tests/test_plugins/test_printer_assignment.py
Normal file
342
tests/test_plugins/test_printer_assignment.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""Machine-level printer assignment and its propagation to the controlling PC.
|
||||
|
||||
The whole point of the feature: the assignment is a property of the MACHINE, so
|
||||
a reimaged bay PC gets its printers back from the asset register with no backup
|
||||
and no restore step. A PC that carries its own assignment keeps it and shadows
|
||||
the machine's, because an exception recorded on the PC is a deliberate one.
|
||||
|
||||
Two surfaces are exercised:
|
||||
GET /api/printers/for-host/<hostname> what a host should install
|
||||
PUT /api/printers/assignments/for-asset/<id> reconcile an asset's set
|
||||
|
||||
Relationship types match `flask seed reference-data`: `usesprinter` means "this
|
||||
printer is installed here" (many), `defaultprinter` means "which of them is the
|
||||
default" (at most one), both directional source -> printer, both propagating
|
||||
read-time through `controls`.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import (
|
||||
Asset,
|
||||
AssetRelationship,
|
||||
AssetType,
|
||||
RelationshipType,
|
||||
)
|
||||
from plugins.printers.models import Printer
|
||||
|
||||
HOST_URL = '/api/printers/for-host/%s'
|
||||
ASSIGN_URL = '/api/printers/assignments/for-asset/%d'
|
||||
|
||||
SHOPFLOOR_HOST = 'SHOPPC01'
|
||||
OFFICE_HOST = 'OFFICEPC01'
|
||||
|
||||
|
||||
def _rows(response):
|
||||
"""The assignment set out of a for-host payload.
|
||||
|
||||
Normalized in one place: the endpoint may return the bare list under `data`
|
||||
or wrap it in a `printers` key, and the settled semantics under test are the
|
||||
same either way.
|
||||
"""
|
||||
payload = response.get_json()['data']
|
||||
if isinstance(payload, dict):
|
||||
payload = payload.get('printers') or []
|
||||
return payload
|
||||
|
||||
|
||||
def _printerids(response):
|
||||
return {row['printerid'] for row in _rows(response)}
|
||||
|
||||
|
||||
def _defaultprinterid(response):
|
||||
"""The one printer flagged default, or None.
|
||||
|
||||
Asserts the at-most-one rule on the way out: two defaults reaching a client
|
||||
means the PC picks whichever it saw last, which is the bug this endpoint
|
||||
exists to make impossible.
|
||||
"""
|
||||
flagged = [row['printerid'] for row in _rows(response) if row.get('isdefault')]
|
||||
assert len(flagged) <= 1, 'more than one printer came back flagged default'
|
||||
return flagged[0] if flagged else None
|
||||
|
||||
|
||||
def _active_defaults(pc):
|
||||
"""Active defaultprinter rows on an asset, read straight from the table.
|
||||
|
||||
The unique constraint is (source, target, type) and does NOT stop two rows
|
||||
with two different targets, so the one-default rule only holds if the write
|
||||
path enforces it. Counted here rather than inferred from the read side.
|
||||
"""
|
||||
dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first()
|
||||
return AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid,
|
||||
relationshiptypeid=dp_type.relationshiptypeid,
|
||||
isactive=True,
|
||||
).all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene(db):
|
||||
"""A bay PC controlling a machine, an office PC controlling nothing, and
|
||||
three printers. No assignments yet: each test builds the ones it needs.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
pc_type = AssetType(assettype='computer', pluginname='computers', tablename='computers')
|
||||
machine_type = AssetType(assettype='machine', pluginname='machines', tablename='machines')
|
||||
printer_type = AssetType(assettype='printer', pluginname='printers', tablename='printers')
|
||||
uses_type = RelationshipType(
|
||||
relationshiptype='usesprinter',
|
||||
description='Asset to a printer installed on it',
|
||||
isdirectional=True,
|
||||
)
|
||||
default_type = RelationshipType(
|
||||
relationshiptype='defaultprinter',
|
||||
description='Asset to its default printer',
|
||||
isdirectional=True,
|
||||
)
|
||||
controls_type = RelationshipType(
|
||||
relationshiptype='controls',
|
||||
description='Operational authority over another asset',
|
||||
isdirectional=True,
|
||||
)
|
||||
db.session.add_all([pc_type, machine_type, printer_type,
|
||||
uses_type, default_type, controls_type])
|
||||
db.session.flush()
|
||||
|
||||
shopfloorpc = Asset(assetnumber='1001', name='Bay PC',
|
||||
assettypeid=pc_type.assettypeid, isactive=True)
|
||||
officepc = Asset(assetnumber='1002', name='Office PC',
|
||||
assettypeid=pc_type.assettypeid, isactive=True)
|
||||
machine = Asset(assetnumber='2001', name='Lathe',
|
||||
assettypeid=machine_type.assettypeid, isactive=True)
|
||||
db.session.add_all([shopfloorpc, officepc, machine])
|
||||
db.session.flush()
|
||||
|
||||
printers = {}
|
||||
for suffix, name in (('A', 'Bay label printer'),
|
||||
('B', 'Bay laser printer'),
|
||||
('C', 'Office laser printer')):
|
||||
asset = Asset(assetnumber='PRN-%s' % suffix, name=name,
|
||||
assettypeid=printer_type.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
printer = Printer(assetid=asset.assetid, windowsname='PRINTER-%s' % suffix,
|
||||
isnetwork=True, hostname='printer-%s' % suffix.lower())
|
||||
db.session.add(printer)
|
||||
printers[suffix] = {'asset': asset, 'printer': printer}
|
||||
|
||||
db.session.add_all([
|
||||
Computer(assetid=shopfloorpc.assetid, hostname=SHOPFLOOR_HOST),
|
||||
Computer(assetid=officepc.assetid, hostname=OFFICE_HOST),
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'shopfloorpc': shopfloorpc,
|
||||
'officepc': officepc,
|
||||
'machine': machine,
|
||||
'printers': printers,
|
||||
'uses_type': uses_type,
|
||||
'default_type': default_type,
|
||||
'controls_type': controls_type,
|
||||
}
|
||||
|
||||
|
||||
def _relate(db, source, target, reltype):
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=source.assetid,
|
||||
targetassetid=target.assetid,
|
||||
relationshiptypeid=reltype.relationshiptypeid,
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _assign(db, scene, owner, suffixes, default=None):
|
||||
"""Write usesprinter rows (and one defaultprinter row) straight to the table."""
|
||||
for suffix in suffixes:
|
||||
_relate(db, owner, scene['printers'][suffix]['asset'], scene['uses_type'])
|
||||
if default:
|
||||
_relate(db, owner, scene['printers'][default]['asset'], scene['default_type'])
|
||||
|
||||
|
||||
def _controls(db, scene):
|
||||
_relate(db, scene['shopfloorpc'], scene['machine'], scene['controls_type'])
|
||||
|
||||
|
||||
def _printerid(scene, suffix):
|
||||
return scene['printers'][suffix]['printer'].printerid
|
||||
|
||||
|
||||
def _assetid(scene, suffix):
|
||||
return scene['printers'][suffix]['asset'].assetid
|
||||
|
||||
|
||||
def test_pc_with_own_assignment_gets_exactly_that(client, db, scene):
|
||||
"""A PC's own rows resolve as-is.
|
||||
|
||||
If this breaks, an assignment recorded against the PC itself either does not
|
||||
reach the host or arrives padded with printers nobody assigned - and the
|
||||
client installs queues on a bay that never asked for them.
|
||||
"""
|
||||
_assign(db, scene, scene['shopfloorpc'], ['A', 'B'], default='A')
|
||||
|
||||
response = client.get(HOST_URL % SHOPFLOOR_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _printerids(response) == {_printerid(scene, 'A'), _printerid(scene, 'B')}
|
||||
assert _defaultprinterid(response) == _printerid(scene, 'A')
|
||||
|
||||
|
||||
def test_pc_without_assignment_inherits_from_the_machine_it_controls(client, db, scene):
|
||||
"""The reimage case, and the reason the feature exists.
|
||||
|
||||
A rebuilt bay PC has no rows of its own. It must still come back with the
|
||||
machine's printers through its `controls` edge. If inheritance is lost, every
|
||||
reimage costs a technician visit again and the asset register stops being the
|
||||
source of truth for what a bay prints on.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A', 'B'], default='B')
|
||||
_controls(db, scene)
|
||||
|
||||
response = client.get(HOST_URL % SHOPFLOOR_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _printerids(response) == {_printerid(scene, 'A'), _printerid(scene, 'B')}
|
||||
assert _defaultprinterid(response) == _printerid(scene, 'B')
|
||||
|
||||
|
||||
def test_own_assignment_overrides_the_machine_rather_than_merging(client, db, scene):
|
||||
"""Own rows shadow inherited ones. They do not add to them.
|
||||
|
||||
A PC row is how a site records a deliberate exception ("this bay's PC prints
|
||||
to the office laser instead"). Merging would silently reinstate exactly the
|
||||
printers the exception was written to remove, and no amount of editing the PC
|
||||
would ever get rid of them.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A', 'B'], default='A')
|
||||
_controls(db, scene)
|
||||
_assign(db, scene, scene['shopfloorpc'], ['C'], default='C')
|
||||
|
||||
response = client.get(HOST_URL % SHOPFLOOR_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _printerids(response) == {_printerid(scene, 'C')}
|
||||
assert _defaultprinterid(response) == _printerid(scene, 'C')
|
||||
|
||||
|
||||
def test_office_pc_controlling_no_machine_resolves_empty(client, db, scene):
|
||||
"""A PC with no machine and no assignment is a valid answer, not an error.
|
||||
|
||||
Most office PCs control nothing. The walk must end quietly and return an
|
||||
empty set: a 404 or a 500 here would make the client script log a failure on
|
||||
every cycle on every office PC, and real failures would drown in it.
|
||||
"""
|
||||
response = client.get(HOST_URL % OFFICE_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _rows(response) == []
|
||||
assert _defaultprinterid(response) is None
|
||||
|
||||
|
||||
def test_default_is_optional(client, db, scene):
|
||||
"""Printers with no default is a legitimate state.
|
||||
|
||||
A bay with three printers and no default exists on the floor. If the API
|
||||
forces a default, a write either fails or invents one, and the client then
|
||||
changes a user's default printer because ShopDB picked arbitrarily.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A', 'B', 'C'])
|
||||
_controls(db, scene)
|
||||
|
||||
response = client.get(HOST_URL % SHOPFLOOR_HOST)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(_rows(response)) == 3
|
||||
assert _defaultprinterid(response) is None
|
||||
assert _active_defaults(scene['machine']) == []
|
||||
|
||||
|
||||
def test_setting_a_default_replaces_the_existing_one(client, db, scene, auth_headers):
|
||||
"""Two active defaults must be impossible.
|
||||
|
||||
The unique constraint is (source, target, type), so a second default INSERTs
|
||||
cleanly and nothing complains. Then the read side returns two, the client
|
||||
picks whichever it iterated last, and the bay's default printer flips at
|
||||
random between cycles.
|
||||
"""
|
||||
first = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={
|
||||
'printerassetids': [_assetid(scene, 'A'), _assetid(scene, 'B')],
|
||||
'defaultprinterassetid': _assetid(scene, 'A'),
|
||||
})
|
||||
assert first.status_code == 200
|
||||
|
||||
second = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={
|
||||
'printerassetids': [_assetid(scene, 'A'), _assetid(scene, 'B')],
|
||||
'defaultprinterassetid': _assetid(scene, 'B'),
|
||||
})
|
||||
assert second.status_code == 200
|
||||
|
||||
defaults = _active_defaults(scene['machine'])
|
||||
assert len(defaults) == 1
|
||||
assert defaults[0].targetassetid == _assetid(scene, 'B')
|
||||
|
||||
_controls(db, scene)
|
||||
response = client.get(HOST_URL % SHOPFLOOR_HOST)
|
||||
assert _defaultprinterid(response) == _printerid(scene, 'B')
|
||||
|
||||
|
||||
def test_default_must_be_one_of_the_assigned_printers(client, db, scene, auth_headers):
|
||||
"""A default outside the assignment set is rejected.
|
||||
|
||||
Otherwise the client is told to default to a queue it was never told to
|
||||
install, fails to set it, and the bay looks broken with nothing in ShopDB
|
||||
showing why.
|
||||
"""
|
||||
response = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={
|
||||
'printerassetids': [_assetid(scene, 'A')],
|
||||
'defaultprinterassetid': _assetid(scene, 'C'),
|
||||
})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert _active_defaults(scene['machine']) == []
|
||||
|
||||
|
||||
def test_unassigning_the_default_printer_clears_the_default(client, db, scene, auth_headers):
|
||||
"""Removing a printer takes its default with it.
|
||||
|
||||
A default row left pointing at an unassigned printer is the dangling case:
|
||||
the printer disappears from the install set while the client is still told to
|
||||
make it the default. Removal is server-side only - it uninstalls nothing.
|
||||
"""
|
||||
assigned = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={
|
||||
'printerassetids': [_assetid(scene, 'A'), _assetid(scene, 'B')],
|
||||
'defaultprinterassetid': _assetid(scene, 'B'),
|
||||
})
|
||||
assert assigned.status_code == 200
|
||||
|
||||
unassigned = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={
|
||||
'printerassetids': [_assetid(scene, 'A')],
|
||||
'defaultprinterassetid': None,
|
||||
})
|
||||
assert unassigned.status_code == 200
|
||||
assert _active_defaults(scene['machine']) == []
|
||||
|
||||
_controls(db, scene)
|
||||
response = client.get(HOST_URL % SHOPFLOOR_HOST)
|
||||
assert _printerids(response) == {_printerid(scene, 'A')}
|
||||
assert _defaultprinterid(response) is None
|
||||
|
||||
|
||||
def test_unknown_hostname_is_a_404(client, db, scene):
|
||||
"""A host ShopDB has never heard of is an error, not an empty set.
|
||||
|
||||
Empty means "this PC is assigned nothing", which the client treats as a safe
|
||||
no-op. A mistyped or unenrolled hostname returning empty looks exactly the
|
||||
same, so a bay would sit unconfigured with nothing anywhere saying its
|
||||
record is missing.
|
||||
"""
|
||||
response = client.get(HOST_URL % 'NOSUCHHOST')
|
||||
|
||||
assert response.status_code == 404
|
||||
120
tests/test_plugins/test_printer_driver_resolution.py
Normal file
120
tests/test_plugins/test_printer_driver_resolution.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Which driver a printer installs with.
|
||||
|
||||
One row per model was unworkable: HP and Xerox universal drivers cover 41 of the
|
||||
reference site's 44 printers, so binding a driver to a single model meant 21
|
||||
near-duplicate rows pointing at one package - a table nobody keeps true, and the
|
||||
reason 42 of 44 printers could not resolve a driver at all.
|
||||
|
||||
The order is most-specific-first, and each step exists for a printer that really
|
||||
is on this floor: a plotter and a card printer need their own driver, everything
|
||||
else takes its make's universal one.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Vendor, Model
|
||||
from plugins.printers.models import Printer, PrinterDriver
|
||||
from plugins.printers.api.asset_routes import _printer_driver
|
||||
|
||||
|
||||
def _universal():
|
||||
return (PrinterDriver.query
|
||||
.filter(PrinterDriver.modelnumberid.is_(None),
|
||||
PrinterDriver.isactive == True)
|
||||
.order_by(PrinterDriver.name).all())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fleet(db):
|
||||
hp = Vendor(vendor='HP')
|
||||
xerox = Vendor(vendor='Xerox')
|
||||
db.session.add_all([hp, xerox])
|
||||
db.session.flush()
|
||||
|
||||
laserjet = Model(modelnumber='LaserJet M602', vendorid=hp.vendorid)
|
||||
designjet = Model(modelnumber='DesignJet T1700', vendorid=hp.vendorid)
|
||||
db.session.add_all([laserjet, designjet])
|
||||
db.session.flush()
|
||||
|
||||
upd = PrinterDriver(name='HP Universal Print Driver', drivername='HP Universal Printing PS',
|
||||
location=r'\\server\share\hp_upd', vendorid=hp.vendorid, isactive=True)
|
||||
plotter = PrinterDriver(name='HP DesignJet T1700', drivername='HP DesignJet T1700dr V4',
|
||||
location=r'\\server\share\designjet', vendorid=hp.vendorid,
|
||||
modelnumberid=designjet.modelnumberid, isactive=True)
|
||||
gpd = PrinterDriver(name='Xerox Global Print Driver PCL6',
|
||||
drivername='Xerox Global Print Driver PCL6',
|
||||
location=r'\\server\share\xerox', vendorid=xerox.vendorid, isactive=True)
|
||||
db.session.add_all([upd, plotter, gpd])
|
||||
db.session.commit()
|
||||
return {'hp': hp, 'xerox': xerox, 'laserjet': laserjet, 'designjet': designjet,
|
||||
'upd': upd, 'plotter': plotter, 'gpd': gpd}
|
||||
|
||||
|
||||
def test_a_printer_takes_its_makes_universal_driver(fleet):
|
||||
"""The case that covers most of a floor: no per-model row exists, and none
|
||||
should have to."""
|
||||
printer = Printer(vendorid=fleet['hp'].vendorid, modelnumberid=fleet['laserjet'].modelnumberid)
|
||||
assert _printer_driver(printer, _universal()).name == 'HP Universal Print Driver'
|
||||
|
||||
|
||||
def test_a_model_specific_driver_beats_the_universal_one(fleet):
|
||||
"""A plotter is not a LaserJet. If the universal driver won here, the
|
||||
DesignJet would be installed with a driver that cannot drive it."""
|
||||
printer = Printer(vendorid=fleet['hp'].vendorid, modelnumberid=fleet['designjet'].modelnumberid)
|
||||
assert _printer_driver(printer, _universal()).name == 'HP DesignJet T1700'
|
||||
|
||||
|
||||
def test_vendors_do_not_bleed_into_each_other(fleet):
|
||||
"""A Xerox must never resolve to the HP driver, whatever the ordering."""
|
||||
printer = Printer(vendorid=fleet['xerox'].vendorid, modelnumberid=None)
|
||||
assert _printer_driver(printer, _universal()).name == 'Xerox Global Print Driver PCL6'
|
||||
|
||||
|
||||
def test_a_printer_with_no_vendor_resolves_to_nothing(fleet):
|
||||
"""Better nothing than a guess: installing the wrong driver is worse than
|
||||
reporting that a printer has none."""
|
||||
printer = Printer(vendorid=None, modelnumberid=None)
|
||||
assert _printer_driver(printer, _universal()) is None
|
||||
|
||||
|
||||
def test_a_make_with_no_driver_row_resolves_to_nothing(db, fleet):
|
||||
"""A vendor nobody has added a driver for is unresolved, not misresolved."""
|
||||
zebra = Vendor(vendor='Zebra')
|
||||
db.session.add(zebra)
|
||||
db.session.commit()
|
||||
printer = Printer(vendorid=zebra.vendorid, modelnumberid=None)
|
||||
assert _printer_driver(printer, _universal()) is None
|
||||
|
||||
|
||||
def test_the_pre_vendorid_naming_convention_still_resolves(db, fleet):
|
||||
"""A site that populated printerdrivers before the column existed matched on
|
||||
the vendor word in the driver's NAME. That must keep working, or an upgrade
|
||||
silently takes every driver away."""
|
||||
epson = Vendor(vendor='Epson')
|
||||
db.session.add(epson)
|
||||
db.session.flush()
|
||||
db.session.add(PrinterDriver(name='Epson ColorWorks universal', drivername='EPSON TM-C3500',
|
||||
location=r'\\server\share\epson', isactive=True))
|
||||
db.session.commit()
|
||||
# vendor attached, not just vendorid: the legacy path reads the vendor's
|
||||
# NAME, which only exists through the relationship.
|
||||
printer = Printer(vendorid=epson.vendorid, modelnumberid=None)
|
||||
printer.vendor = epson
|
||||
assert _printer_driver(printer, _universal()).name == 'Epson ColorWorks universal'
|
||||
|
||||
|
||||
def test_a_row_that_names_a_vendor_is_not_matched_by_its_text(db, fleet):
|
||||
"""A driver whose vendorid is set and does NOT match must not then be picked
|
||||
up by the name convention: a mis-set vendor would resolve to the wrong
|
||||
package, which is worse than resolving to none."""
|
||||
brother = Vendor(vendor='Brother')
|
||||
db.session.add(brother)
|
||||
db.session.flush()
|
||||
# Named for Brother, but bound to HP by id - the id is the truth.
|
||||
db.session.add(PrinterDriver(name='Brother universal', drivername='Brother Universal',
|
||||
location=r'\\server\share\brother',
|
||||
vendorid=fleet['hp'].vendorid, isactive=True))
|
||||
db.session.commit()
|
||||
printer = Printer(vendorid=brother.vendorid, modelnumberid=None)
|
||||
printer.vendor = brother
|
||||
assert _printer_driver(printer, _universal()) is None
|
||||
Reference in New Issue
Block a user