Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2083029ff2 | ||
|
|
96f127f8c8 | ||
|
|
f34b9ca710 |
@@ -22,12 +22,12 @@ at all yet.
|
|||||||
| Artifact | Where it is today |
|
| Artifact | Where it is today |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
|
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
|
||||||
| `Report-AssetToShopDB.ps1` | **Not in this repository.** It lives on the reference site's imaging share and is provided on request. It is planned to move to `plugins/computers/client/` so it versions with the collector contract it implements. |
|
| `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. |
|
||||||
| EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). |
|
| EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). |
|
||||||
|
|
||||||
Ask the maintainers for the two that are not here. A site that would rather not
|
A site that would rather not run a binary it cannot rebuild should take
|
||||||
run a binary it cannot rebuild should take EventSaver's source and compile it
|
EventSaver's source and compile it locally - the build needs no SDK and is one
|
||||||
locally - the build needs no SDK and is one command.
|
command.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone.
|
|||||||
|
|
||||||
| Input | Where it comes from |
|
| Input | Where it comes from |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Server URL | `-ApiUrl https://<your-shopdb>/api/collector/computers` |
|
| Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it |
|
||||||
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` |
|
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script |
|
||||||
|
| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites |
|
||||||
|
|
||||||
|
**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter
|
||||||
|
generates this script with your server's URL and ranges already in the parameter
|
||||||
|
defaults, and shows its SHA-256. The key is deliberately not included.
|
||||||
|
|
||||||
### Minting the key on your ShopDB
|
### Minting the key on your ShopDB
|
||||||
|
|
||||||
@@ -142,13 +147,27 @@ Configuration ShopdbCollector
|
|||||||
Ensure = 'Present'
|
Ensure = 'Present'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Optional. Only for a site whose bays carry both a controller NIC and a
|
||||||
|
# corporate one AND whose default route is not the corporate NIC. Leave
|
||||||
|
# this resource out otherwise - the script picks the default-route NIC.
|
||||||
|
Registry CollectorRanges
|
||||||
|
{
|
||||||
|
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
|
||||||
|
ValueName = 'CollectorRanges'
|
||||||
|
ValueData = '10.20.0.0/23,10.21.4.0/26'
|
||||||
|
ValueType = 'String'
|
||||||
|
Ensure = 'Present'
|
||||||
|
}
|
||||||
|
|
||||||
Script ReportingTask
|
Script ReportingTask
|
||||||
{
|
{
|
||||||
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
|
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
|
||||||
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
|
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
|
||||||
SetScript = {
|
SetScript = {
|
||||||
|
# No -ApiUrl: BaseUrl above is where the script reads it from,
|
||||||
|
# and one source beats two that can disagree.
|
||||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||||||
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"'
|
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"'
|
||||||
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
|
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
|
||||||
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
|
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
|
||||||
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force
|
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ because the failure would otherwise be visible to the whole floor.
|
|||||||
**Folder mode** predates the server and stays as a fallback for a site with no
|
**Folder mode** predates the server and stays as a fallback for a site with no
|
||||||
ShopDB instance yet, or for content nobody wants in the database.
|
ShopDB instance yet, or for content nobody wants in the database.
|
||||||
|
|
||||||
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the
|
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
|
||||||
> binary falls back to a path compiled into `EventSaver.cs` - and that path
|
> no source to read and the screensaver shows nothing. That is deliberate: the
|
||||||
> belongs to the site it was first built for. Ship the ini. A missing ini is not
|
> compiled-in fallback used to be the path of the site it was first built for, so
|
||||||
> a neutral default.
|
> a missing ini silently pointed a new site at someone else's file server. It is
|
||||||
|
> now empty, and failing visibly beats displaying the wrong site's slides. Ship
|
||||||
|
> the ini.
|
||||||
|
|
||||||
## What decides the running order
|
## What decides the running order
|
||||||
|
|
||||||
@@ -98,8 +100,9 @@ covering something someone needed to see.
|
|||||||
|
|
||||||
## Building it
|
## Building it
|
||||||
|
|
||||||
No SDK required - it compiles with the in-box .NET Framework compiler on any
|
The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
|
||||||
Windows 10 or 11 machine:
|
compiles with the in-box .NET Framework compiler on any Windows 10 or 11
|
||||||
|
machine:
|
||||||
|
|
||||||
```
|
```
|
||||||
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
|
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
|
||||||
|
|||||||
@@ -1697,6 +1697,14 @@
|
|||||||
"params": "protocol_id in path; body: any of protocolname, port, isactive",
|
"params": "protocol_id in path; body: any of protocolname, port, isactive",
|
||||||
"purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against",
|
"purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against",
|
||||||
"example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3"
|
"example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/computers/client-script",
|
||||||
|
"auth": "jwt + role:admin",
|
||||||
|
"params": "none",
|
||||||
|
"purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched",
|
||||||
|
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"surface": "plugin-computers"
|
"surface": "plugin-computers"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "ShopDB Flask API",
|
"title": "ShopDB Flask API",
|
||||||
"version": "0.10.0",
|
"version": "0.11.2",
|
||||||
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
|
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
@@ -9892,6 +9892,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/computers/client-script": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-computers"
|
||||||
|
],
|
||||||
|
"summary": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the...",
|
||||||
|
"description": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched\n\n**Auth:** jwt + role:admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/measuringtools/types": {
|
"/api/measuringtools/types": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
161
docs/proposals/printer-assignment.md
Normal file
161
docs/proposals/printer-assignment.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Proposal: assign printers to a PC in ShopDB, let the PC install them
|
||||||
|
|
||||||
|
Status: PROPOSED. Not built.
|
||||||
|
Author: planning session 2026-08-18.
|
||||||
|
|
||||||
|
## 1. What this is
|
||||||
|
|
||||||
|
Today a printer reaches a shop-floor PC because a person walks up to it, opens
|
||||||
|
the printer installer, finds the printer on a floor plan and clicks it. That is
|
||||||
|
fine for someone choosing a printer, and wrong for a bay whose printers are a
|
||||||
|
property of the bay.
|
||||||
|
|
||||||
|
This proposal makes the assignment data: edit a PC in ShopDB, tick the printers
|
||||||
|
that belong on it, mark one default. The PC converges on its next GE-Enforce
|
||||||
|
cycle - installing what is missing and setting the default - and keeps
|
||||||
|
converging, so a reimaged bay comes back with its printers and a bay that drifts
|
||||||
|
is corrected.
|
||||||
|
|
||||||
|
The map installer stays, for the case it is actually good at: a person at an
|
||||||
|
unmanaged or office PC picking a printer that nobody assigned.
|
||||||
|
|
||||||
|
## 2. Why it is worth doing
|
||||||
|
|
||||||
|
- **The assignment becomes a record.** "Which printers does bay 2107 have" is a
|
||||||
|
question ShopDB can answer, and today it cannot.
|
||||||
|
- **A reimage stops costing a visit.** The bay reinstalls its own printers.
|
||||||
|
- **Drift is corrected, not just detected.** A queue deleted by a user comes
|
||||||
|
back.
|
||||||
|
- **It removes the walk-up from the common case.** The installer's map remains
|
||||||
|
for the uncommon one.
|
||||||
|
|
||||||
|
## 3. What already exists
|
||||||
|
|
||||||
|
Most of the model is in place, which is why this is a small feature rather than
|
||||||
|
a project.
|
||||||
|
|
||||||
|
| piece | state |
|
||||||
|
|---|---|
|
||||||
|
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
|
||||||
|
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
|
||||||
|
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
|
||||||
|
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
|
||||||
|
| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) |
|
||||||
|
| Per-printer install path | `Printer.installpath` |
|
||||||
|
| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` |
|
||||||
|
| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle |
|
||||||
|
| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` |
|
||||||
|
|
||||||
|
## 4. What has to be built
|
||||||
|
|
||||||
|
### 4.1 One endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/printers/for-host/<hostname>
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the printers assigned to that PC and which is default, each with what a
|
||||||
|
client needs to install it: queue name, host or IP, port, driver name, driver
|
||||||
|
location.
|
||||||
|
|
||||||
|
Resolved by hostname, not machine number: the collector already upserts PCs by
|
||||||
|
hostname, and an office PC has no machine number.
|
||||||
|
|
||||||
|
**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope
|
||||||
|
and PC type and sync broadly; putting per-PC rows there would leak every bay's
|
||||||
|
configuration to every bay and grow without limit. One manifest entry runs one
|
||||||
|
script that asks the API what THIS host gets - the mirror image of
|
||||||
|
`Report-AssetToShopDB.ps1`.
|
||||||
|
|
||||||
|
### 4.2 Two columns on `printerdrivers`
|
||||||
|
|
||||||
|
- `drivername` - the driver's exact name as the INF declares it, e.g.
|
||||||
|
`HP Universal Printing PCL 6`. `Add-PrinterDriver` needs it verbatim, and a
|
||||||
|
mismatch is the usual failure. Deriving it by parsing the INF on hundreds of
|
||||||
|
bays is fragile; a human confirming it once in ShopDB is not.
|
||||||
|
- `installmethod` - `pnputil` or `dpinst`. See section 6: if Brother really is
|
||||||
|
absent from the fleet, everything is `pnputil` and this column can wait.
|
||||||
|
|
||||||
|
### 4.3 UI on the PC form
|
||||||
|
|
||||||
|
A printer picker writing `defaultprinter` (one) and an assignment list (many).
|
||||||
|
`AssetRelationships.vue` already edits relationships; this is a narrowed case of
|
||||||
|
it.
|
||||||
|
|
||||||
|
### 4.4 One client script, in two contexts
|
||||||
|
|
||||||
|
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
|
||||||
|
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
|
||||||
|
|
||||||
|
*As SYSTEM, every cycle:*
|
||||||
|
|
||||||
|
1. `GET /api/printers/for-host/$env:COMPUTERNAME`
|
||||||
|
2. For each assigned printer with no queue: trust the driver catalog's cert,
|
||||||
|
`pnputil /add-driver`, create the port, create the queue
|
||||||
|
3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter`
|
||||||
|
4. Ensure the per-user task exists
|
||||||
|
|
||||||
|
*In the user's context, at logon and on a repeat:*
|
||||||
|
|
||||||
|
5. Read that value, compare with the current default, set it if it differs, and
|
||||||
|
clear "Let Windows manage my default printer" - otherwise Windows silently
|
||||||
|
overrides the choice the next time someone prints elsewhere
|
||||||
|
|
||||||
|
The default printer is per-user state, which is the only reason this needs two
|
||||||
|
contexts. Everything else is machine state and belongs to the cycle.
|
||||||
|
|
||||||
|
Converge, do not reinstall: when the state matches, the script does nothing.
|
||||||
|
Nothing here needs the manifest to know when a printer changes, because the
|
||||||
|
desired state is fetched, not declared.
|
||||||
|
|
||||||
|
## 5. Decisions to take before writing code
|
||||||
|
|
||||||
|
1. **Never remove a queue by default.** A transient API failure would otherwise
|
||||||
|
strip printers fleet-wide. Deletion is an explicit opt-in, per PC.
|
||||||
|
2. **Enforced or set-once for the default?** Re-applying every cycle overrides a
|
||||||
|
user who chose their own default - correct for a locked bay, irritating on an
|
||||||
|
office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag
|
||||||
|
rather than one global answer.
|
||||||
|
3. **Failure is silent and safe**: unreachable API means change nothing, log,
|
||||||
|
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
|
||||||
|
|
||||||
|
## 6. What the fleet data says, and the one prerequisite
|
||||||
|
|
||||||
|
The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1.
|
||||||
|
|
||||||
|
- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD,
|
||||||
|
Xerox Global Print Driver). One driver record each serves every queue of that
|
||||||
|
make.
|
||||||
|
- **There are no Brother printers at all**, yet the installer carries 208 files
|
||||||
|
of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices
|
||||||
|
with no Printer-class INF, which is the only reason a second staging method
|
||||||
|
(DPInst) exists. If production confirms no Brother, that payload and that code
|
||||||
|
path can both go.
|
||||||
|
- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is
|
||||||
|
a fourth special case - a PostScript device the UPD does not cover.
|
||||||
|
|
||||||
|
**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it
|
||||||
|
points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the
|
||||||
|
universal driver - the opposite of how a UPD should be used. The table needs
|
||||||
|
roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its
|
||||||
|
payload is restored. Nothing in this proposal works until a printer can resolve
|
||||||
|
to a driver.
|
||||||
|
|
||||||
|
## 7. Deployment constraint that shapes the design
|
||||||
|
|
||||||
|
**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching
|
||||||
|
a share path must run as a manifest entry inside that cycle, never as its own
|
||||||
|
scheduled task. The failure is silent - the task reports 0 processed, 0
|
||||||
|
installed, 0 failed - and it has cost a session before.
|
||||||
|
|
||||||
|
This is why driver staging belongs in the cycle even though the per-user default
|
||||||
|
does not, and why "the assignment script schedules a task that installs drivers"
|
||||||
|
is the wrong shape.
|
||||||
|
|
||||||
|
## 8. What this does not change
|
||||||
|
|
||||||
|
- The printer installer keeps working, for walk-up and self-service.
|
||||||
|
- Nothing about how printers are modelled, mapped or reported.
|
||||||
|
- The collector contract.
|
||||||
|
- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or
|
||||||
|
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<!-- With coordinates, the name carries the same floor-plan preview
|
<!-- With coordinates, the name carries the same floor-plan preview
|
||||||
the asset's own page uses. Without them, a plain link. -->
|
the asset's own page uses. Without them, a plain link. -->
|
||||||
<LocationMapTooltip v-if="row.link && row.maphover"
|
<LocationMapTooltip v-if="row.link && row.maphover"
|
||||||
:left="row.maphover.x" :top="row.maphover.y"
|
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
|
||||||
:machineName="row.maphover.label">
|
:machineName="row.maphover.label">
|
||||||
<router-link :to="row.link" class="dc-row-title"
|
<router-link :to="row.link" class="dc-row-title"
|
||||||
:title="row.titletip || undefined">
|
:title="row.titletip || undefined">
|
||||||
|
|||||||
@@ -127,7 +127,15 @@ export function mapHover(card, item) {
|
|||||||
const x = item[spec.x]
|
const x = item[spec.x]
|
||||||
const y = item[spec.y]
|
const y = item[spec.y]
|
||||||
if (x === null || x === undefined || y === null || y === undefined) return null
|
if (x === null || x === undefined || y === null || y === undefined) return null
|
||||||
return { x, y, label: spec.label ? (item[spec.label] || '') : '' }
|
// The level travels with the coordinates (ADR-017) - they are pixels of ONE
|
||||||
|
// drawing. Read `levelid` unless the card names another field, so a card that
|
||||||
|
// predates levels still previews on the right floor instead of none.
|
||||||
|
const levelid = item[spec.level || 'levelid']
|
||||||
|
return {
|
||||||
|
x, y,
|
||||||
|
levelid: levelid === undefined ? null : levelid,
|
||||||
|
label: spec.label ? (item[spec.label] || '') : '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cardRows(card) {
|
export function cardRows(card) {
|
||||||
|
|||||||
@@ -289,7 +289,12 @@ async function exportPdf() {
|
|||||||
try {
|
try {
|
||||||
await loadMapConfig()
|
await loadMapConfig()
|
||||||
await exportMapPdf({
|
await exportMapPdf({
|
||||||
assets: filteredAssets.value,
|
// Only this level's markers. The sheet is one drawing, so a marker
|
||||||
|
// positioned against another level would be printed on the wrong floor
|
||||||
|
// plan - the same failure the on-screen map had, in a form nobody can
|
||||||
|
// correct after it is printed and carried onto the floor.
|
||||||
|
assets: filteredAssets.value.filter(
|
||||||
|
asset => (asset.levelid ?? null) === shownLevelId.value),
|
||||||
// blueprintUrlFor applies withBase - the raw setting value is a
|
// blueprintUrlFor applies withBase - the raw setting value is a
|
||||||
// root-relative /api path, which 404s under a subpath mount like /ops.
|
// root-relative /api path, which 404s under a subpath mount like /ops.
|
||||||
// The PDF always uses the light blueprint: it prints on white paper.
|
// The PDF always uses the light blueprint: it prints on white paper.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Computers plugin API endpoints."""
|
"""Computers plugin API endpoints."""
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, Response, current_app
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||||
|
|
||||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||||
|
|
||||||
from shopdb.api import require_permission, apply_import_timestamps
|
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||||
|
|
||||||
computers_bp = Blueprint('computers', __name__)
|
computers_bp = Blueprint('computers', __name__)
|
||||||
|
|
||||||
@@ -1064,3 +1064,112 @@ def dashboard_sharedmachines():
|
|||||||
|
|
||||||
out.sort(key=lambda r: -r['pccount'])
|
out.sort(key=lambda r: -r['pccount'])
|
||||||
return success_response(out)
|
return success_response(out)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Collector client script
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1'
|
||||||
|
|
||||||
|
|
||||||
|
def _client_script_path():
|
||||||
|
"""The reporter shipped with this plugin, which is the single source."""
|
||||||
|
import os
|
||||||
|
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
'client', CLIENT_SCRIPT_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def _setting_value(key):
|
||||||
|
row = Setting.query.filter_by(key=key).first()
|
||||||
|
return ((row.value if row else '') or '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_client_script(source: str, baseurl: str, ranges: str,
|
||||||
|
version: str, generatedon: str) -> str:
|
||||||
|
"""Stamp a site's own values into the reporter's parameter defaults.
|
||||||
|
|
||||||
|
ONLY the defaults are substituted, never the body: the file in the repo
|
||||||
|
stays runnable as-is, so there is no second copy to drift. Everything
|
||||||
|
stamped here is overridable at runtime - the parameter still wins, then the
|
||||||
|
registry - because a bay may need to differ from its site.
|
||||||
|
|
||||||
|
The collector key is NOT stamped in. This file lands on every shop-floor PC,
|
||||||
|
and a token in a file on hundreds of bays cannot be rotated quietly; it is
|
||||||
|
read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per
|
||||||
|
ADOPTING-AT-ANOTHER-SITE.md.
|
||||||
|
"""
|
||||||
|
apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else ''
|
||||||
|
header = (
|
||||||
|
'# GENERATED by ShopDB {version} on {generatedon}\n'
|
||||||
|
'# for {baseurl}\n'
|
||||||
|
'#\n'
|
||||||
|
'# Re-download after upgrading ShopDB: this copy matches that server\'s\n'
|
||||||
|
'# collector contract. Edits here are lost on the next download - change\n'
|
||||||
|
'# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n'
|
||||||
|
'#\n'
|
||||||
|
'# The collector key is deliberately NOT in this file. Provision it as\n'
|
||||||
|
'# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n'
|
||||||
|
'\n'
|
||||||
|
).format(version=version, generatedon=generatedon,
|
||||||
|
baseurl=baseurl or 'an unconfigured site (set site_base_url)')
|
||||||
|
|
||||||
|
out = source
|
||||||
|
if apiurl:
|
||||||
|
old = "[string]$ApiUrl = ''"
|
||||||
|
assert old in out, 'the reporter no longer declares $ApiUrl as expected'
|
||||||
|
out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1)
|
||||||
|
if ranges:
|
||||||
|
old = "[string]$AllowedRanges = ''"
|
||||||
|
assert old in out, 'the reporter no longer declares $AllowedRanges as expected'
|
||||||
|
out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1)
|
||||||
|
return header + out
|
||||||
|
|
||||||
|
|
||||||
|
@computers_bp.route('/client-script', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def download_client_script():
|
||||||
|
"""The collector reporter, stamped with THIS site's values.
|
||||||
|
|
||||||
|
Admin-only. It carries no secret, but it does state a site's URL and its
|
||||||
|
internal ranges, which is configuration rather than something to hand out.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = _client_script_path()
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'The collector script is not present in this install',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
with open(path, 'r', encoding='utf-8') as handle:
|
||||||
|
source = handle.read()
|
||||||
|
|
||||||
|
# A site that has not set its public URL still gets a usable script: the
|
||||||
|
# browsing origin is the server the admin is talking to right now.
|
||||||
|
baseurl = _setting_value('site_base_url') or request.url_root
|
||||||
|
# From config, not an import: a plugin reaching into core is an ADR-002
|
||||||
|
# violation and the contract test fails the build for it.
|
||||||
|
version = current_app.config.get('VERSION') or 'unknown'
|
||||||
|
generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = _generate_client_script(
|
||||||
|
source, baseurl.strip(), _setting_value('computers_routableranges'),
|
||||||
|
version, generatedon)
|
||||||
|
except AssertionError as exc:
|
||||||
|
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
|
||||||
|
|
||||||
|
digest = hashlib.sha256(body.encode('utf-8')).hexdigest()
|
||||||
|
return Response(
|
||||||
|
body,
|
||||||
|
mimetype='text/plain; charset=utf-8',
|
||||||
|
headers={
|
||||||
|
'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME),
|
||||||
|
# Published so a deployment can verify what it fetched, the same way
|
||||||
|
# the installer publishes one.
|
||||||
|
'X-Script-Sha256': digest,
|
||||||
|
})
|
||||||
|
|||||||
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' }
|
meta: { requiresAuth: true, plugin: 'computers' }
|
||||||
},
|
},
|
||||||
// Computer-specific settings
|
// Computer-specific settings
|
||||||
|
{
|
||||||
|
path: 'settings/collector',
|
||||||
|
name: 'collector-settings',
|
||||||
|
component: () => import('./views/CollectorSettings.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/pctypes',
|
path: 'settings/pctypes',
|
||||||
name: 'pctypes',
|
name: 'pctypes',
|
||||||
|
|||||||
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"
|
v-if="computer.mapx != null && computer.mapy != null"
|
||||||
:left="computer.mapx"
|
:left="computer.mapx"
|
||||||
:top="computer.mapy"
|
:top="computer.mapy"
|
||||||
|
:levelid="computer.levelid"
|
||||||
:machineName="computer.assetnumber"
|
:machineName="computer.assetnumber"
|
||||||
>
|
>
|
||||||
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
||||||
|
|||||||
@@ -162,6 +162,25 @@ class ComputersPlugin(BasePlugin):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_settings_cards(self) -> List[dict]:
|
||||||
|
"""The asset reporter's own settings page.
|
||||||
|
|
||||||
|
It is a settings card rather than a docs page because it does two things
|
||||||
|
an operator needs at the same moment: name this site's routable ranges,
|
||||||
|
and download the reporter that carries them.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'group': 'Computers',
|
||||||
|
'to': '/settings/collector',
|
||||||
|
'icon': 'download',
|
||||||
|
'title': 'Asset reporter',
|
||||||
|
'description': 'Download the collector script stamped with this '
|
||||||
|
'site\'s URL and ranges',
|
||||||
|
'position': 26,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def get_settings_defaults(self) -> List[dict]:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
"""Settings this plugin owns.
|
"""Settings this plugin owns.
|
||||||
|
|
||||||
@@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'description': 'Hours without a collector report before a PC '
|
'description': 'Hours without a collector report before a PC '
|
||||||
'is listed as not reporting on the dashboard.',
|
'is listed as not reporting on the dashboard.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'key': 'computers_routableranges',
|
||||||
|
'value': '',
|
||||||
|
'valuetype': 'string',
|
||||||
|
'category': 'computers',
|
||||||
|
'description': 'Comma-separated CIDRs naming this site\'s '
|
||||||
|
'routable ranges, e.g. 10.20.0.0/23,10.21.4.0/26. '
|
||||||
|
'Stamped into the collector script this server '
|
||||||
|
'generates, so a bay with a controller NIC and a '
|
||||||
|
'corporate NIC reports the right one. Blank uses '
|
||||||
|
'the NIC carrying the default route, which needs '
|
||||||
|
'no knowledge of a site\'s addressing.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'key': 'computers_machinelink_alerts',
|
'key': 'computers_machinelink_alerts',
|
||||||
'value': 'false',
|
'value': 'false',
|
||||||
|
|||||||
@@ -1223,6 +1223,9 @@ def list_reports():
|
|||||||
'location': known.get('location'),
|
'location': known.get('location'),
|
||||||
'mapx': known.get('mapx'),
|
'mapx': known.get('mapx'),
|
||||||
'mapy': known.get('mapy'),
|
'mapy': known.get('mapy'),
|
||||||
|
# Coordinates are pixels of ONE level (ADR-017), so the level goes
|
||||||
|
# with them or the hover preview has nothing to draw on.
|
||||||
|
'levelid': known.get('levelid'),
|
||||||
'machinenumber': known.get('machinenumber'),
|
'machinenumber': known.get('machinenumber'),
|
||||||
'machineassetid': known.get('machineassetid'),
|
'machineassetid': known.get('machineassetid'),
|
||||||
'machinepluginid': known.get('machinepluginid'),
|
'machinepluginid': known.get('machinepluginid'),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
empty map would be worse than none. -->
|
empty map would be worse than none. -->
|
||||||
<LocationMapTooltip
|
<LocationMapTooltip
|
||||||
v-if="report.mapx != null && report.mapy != null"
|
v-if="report.mapx != null && report.mapy != null"
|
||||||
:left="report.mapx" :top="report.mapy"
|
:left="report.mapx" :top="report.mapy" :levelid="report.levelid"
|
||||||
:machineName="report.hostname"
|
:machineName="report.hostname"
|
||||||
>
|
>
|
||||||
<span class="map-pin" :title="report.location || 'On the floor plan'">◎</span>
|
<span class="map-pin" :title="report.location || 'On the floor plan'">◎</span>
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
v-if="machine.mapx != null && machine.mapy != null"
|
v-if="machine.mapx != null && machine.mapy != null"
|
||||||
:left="machine.mapx"
|
:left="machine.mapx"
|
||||||
:top="machine.mapy"
|
:top="machine.mapy"
|
||||||
|
:levelid="machine.levelid"
|
||||||
:machineName="machine.assetnumber"
|
:machineName="machine.assetnumber"
|
||||||
>
|
>
|
||||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||||
|
|||||||
@@ -1042,7 +1042,7 @@ def _get_low_supplies_data():
|
|||||||
'model': model_number,
|
'model': model_number,
|
||||||
'location': location_name,
|
'location': location_name,
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
'levelid': asset.levelid,
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
'supplies': annotated
|
'supplies': annotated
|
||||||
})
|
})
|
||||||
@@ -1479,6 +1479,10 @@ def dashboard_supplies():
|
|||||||
# card, it just has nothing to preview.
|
# card, it just has nothing to preview.
|
||||||
'mapx': printer.get('mapx'),
|
'mapx': printer.get('mapx'),
|
||||||
'mapy': printer.get('mapy'),
|
'mapy': printer.get('mapy'),
|
||||||
|
# The level those pixels belong to (ADR-017). Without it the hover
|
||||||
|
# preview cannot draw the marker and says so, which is what the
|
||||||
|
# dashboard card and the toner report were both doing.
|
||||||
|
'levelid': printer.get('levelid'),
|
||||||
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
||||||
'supplies': [{
|
'supplies': [{
|
||||||
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
v-if="printer.mapx != null && printer.mapy != null"
|
v-if="printer.mapx != null && printer.mapy != null"
|
||||||
:left="printer.mapx"
|
:left="printer.mapx"
|
||||||
:top="printer.mapy"
|
:top="printer.mapy"
|
||||||
|
:levelid="printer.levelid"
|
||||||
:machineName="printer.name || printer.assetnumber"
|
:machineName="printer.name || printer.assetnumber"
|
||||||
>
|
>
|
||||||
<span class="location-link">View on Map</span>
|
<span class="location-link">View on Map</span>
|
||||||
|
|||||||
@@ -43,14 +43,13 @@
|
|||||||
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
||||||
>
|
>
|
||||||
<template v-if="page[pos - 1]">
|
<template v-if="page[pos - 1]">
|
||||||
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
<div class="csf-name">{{ labelName(page[pos - 1]) }}</div>
|
||||||
<div class="qr-container">
|
<div class="qr-container">
|
||||||
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
|
||||||
<div class="info-inner">
|
<div class="info-inner">
|
||||||
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
|
<div v-if="fqdnFor(page[pos - 1])" class="info-row">{{ fqdnFor(page[pos - 1]) }}</div>
|
||||||
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import { renderQrDataUrl } from '@/utils/codes'
|
import { renderQrDataUrl } from '@/utils/codes'
|
||||||
import { buildQrUrl } from '@/utils/qrTarget'
|
import { buildQrUrl } from '@/utils/qrTarget'
|
||||||
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
const printers = ref([])
|
const printers = ref([])
|
||||||
const selectedPrinters = ref([])
|
const selectedPrinters = ref([])
|
||||||
@@ -91,6 +91,7 @@ const pages = computed(() => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||||
// listAll: perpage is clamped to 100, and a batch sheet must cover every
|
// listAll: perpage is clamped to 100, and a batch sheet must cover every
|
||||||
// printer, not the first page of them.
|
// printer, not the first page of them.
|
||||||
printers.value = await printersApi.listAll()
|
printers.value = await printersApi.listAll()
|
||||||
@@ -129,6 +130,29 @@ async function generateQRCodes() {
|
|||||||
qrImages.value = next
|
qrImages.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The printer's FQDN. A stored hostname wins; otherwise the site builds one
|
||||||
|
// from the IP through the printer_hostname_template setting (ADR-015), which is
|
||||||
|
// how PrinterForm and the toner report derive it.
|
||||||
|
const hostnameTemplate = ref('')
|
||||||
|
|
||||||
|
function fqdnFor(item) {
|
||||||
|
const stored = item?.printer?.hostname
|
||||||
|
if (stored) return stored
|
||||||
|
const ip = getIp(item)
|
||||||
|
if (!ip || !hostnameTemplate.value) return ''
|
||||||
|
return hostnameTemplate.value.replace('{ip}', ip.replace(/\./g, '-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// What goes on the label's prominent top line: the printer's NAME, e.g.
|
||||||
|
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
|
||||||
|
// queue name when one is set, otherwise the asset's name, and in practice most
|
||||||
|
// printers carry it as the assetnumber and nothing else, so that is the last
|
||||||
|
// fallback rather than a blank label. This is a name, not an identifier line:
|
||||||
|
// the label deliberately carries no separate "Asset #".
|
||||||
|
function labelName(item) {
|
||||||
|
return item?.printer?.windowsname || item?.name || item?.assetnumber || ''
|
||||||
|
}
|
||||||
|
|
||||||
function displayName(printer) {
|
function displayName(printer) {
|
||||||
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
||||||
}
|
}
|
||||||
@@ -286,7 +310,7 @@ function print() {
|
|||||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||||
.info-inner { text-align: left; }
|
.info-inner { text-align: left; }
|
||||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
|
||||||
.empty-label { color: #999; font-size: 14px; }
|
.empty-label { color: #999; font-size: 14px; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
|||||||
@@ -24,14 +24,13 @@
|
|||||||
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
|
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
|
||||||
>
|
>
|
||||||
<template v-if="pos === parseInt(position)">
|
<template v-if="pos === parseInt(position)">
|
||||||
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
|
<div class="csf-name">{{ labelName }}</div>
|
||||||
<div class="qr-container">
|
<div class="qr-container">
|
||||||
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="csf-name">{{ printer.assetnumber }}</div>
|
|
||||||
<div class="info-inner">
|
<div class="info-inner">
|
||||||
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
|
<div v-if="fqdn" class="info-row">{{ fqdn }}</div>
|
||||||
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
|
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +48,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import { renderQrDataUrl } from '@/utils/codes'
|
import { renderQrDataUrl } from '@/utils/codes'
|
||||||
import { buildQrUrl } from '@/utils/qrTarget'
|
import { buildQrUrl } from '@/utils/qrTarget'
|
||||||
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -58,6 +58,18 @@ const position = ref('1')
|
|||||||
// in print output, images print every time.
|
// in print output, images print every time.
|
||||||
const qrImage = ref('')
|
const qrImage = ref('')
|
||||||
|
|
||||||
|
// What goes on the label's prominent top line: the printer's NAME, e.g.
|
||||||
|
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
|
||||||
|
// queue name when one is set, otherwise the asset's name, and in practice most
|
||||||
|
// printers carry it as the assetnumber and nothing else, so that is the last
|
||||||
|
// fallback rather than a blank label. This is a name, not an identifier line:
|
||||||
|
// the label deliberately carries no separate "Asset #".
|
||||||
|
const labelName = computed(() =>
|
||||||
|
printer.value?.printer?.windowsname
|
||||||
|
|| printer.value?.name
|
||||||
|
|| printer.value?.assetnumber
|
||||||
|
|| '')
|
||||||
|
|
||||||
const ipAddress = computed(() => {
|
const ipAddress = computed(() => {
|
||||||
// Check direct ipaddress field first (from list API)
|
// Check direct ipaddress field first (from list API)
|
||||||
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
|
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
|
||||||
@@ -67,8 +79,21 @@ const ipAddress = computed(() => {
|
|||||||
return primary?.ipaddress || primary?.address || null
|
return primary?.ipaddress || primary?.address || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The printer's FQDN. A stored hostname wins; otherwise the site builds one from
|
||||||
|
// the IP through the printer_hostname_template setting (ADR-015), the same way
|
||||||
|
// PrinterForm and the toner report derive it.
|
||||||
|
const hostnameTemplate = ref('')
|
||||||
|
|
||||||
|
const fqdn = computed(() => {
|
||||||
|
const stored = printer.value?.printer?.hostname
|
||||||
|
if (stored) return stored
|
||||||
|
if (!ipAddress.value || !hostnameTemplate.value) return ''
|
||||||
|
return hostnameTemplate.value.replace('{ip}', ipAddress.value.replace(/\./g, '-'))
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||||
const response = await printersApi.get(route.params.id)
|
const response = await printersApi.get(route.params.id)
|
||||||
printer.value = response.data.data
|
printer.value = response.data.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -169,7 +194,7 @@ function print() {
|
|||||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||||
.info-inner { text-align: left; }
|
.info-inner { text-align: left; }
|
||||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
/* Force rendered images/colors to print even when "Background graphics" is
|
/* Force rendered images/colors to print even when "Background graphics" is
|
||||||
|
|||||||
@@ -1,378 +1,379 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Toner Report</h1>
|
<h1>Toner Report</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||||
:columns="emailColumns" :rows="emailRows" />
|
:columns="emailColumns" :rows="emailRows" />
|
||||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading">Loading supply data...</div>
|
<div v-if="loading" class="loading">Loading supply data...</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="card">
|
<div v-else-if="error" class="card">
|
||||||
<p class="text-danger">{{ error }}</p>
|
<p class="text-danger">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Summary Bar -->
|
<!-- Summary Bar -->
|
||||||
<div class="summary-bar">
|
<div class="summary-bar">
|
||||||
<div class="summary-stat card">
|
<div class="summary-stat card">
|
||||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||||
<div class="stat-label">Printers Checked</div>
|
<div class="stat-label">Printers Checked</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-stat card stat-low">
|
<div class="summary-stat card stat-low">
|
||||||
<div class="stat-value">{{ summary.low }}</div>
|
<div class="stat-value">{{ summary.low }}</div>
|
||||||
<div class="stat-label">Low Supply</div>
|
<div class="stat-label">Low Supply</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-stat card stat-critical">
|
<div class="summary-stat card stat-critical">
|
||||||
<div class="stat-value">{{ summary.critical }}</div>
|
<div class="stat-value">{{ summary.critical }}</div>
|
||||||
<div class="stat-label">Critical Supply</div>
|
<div class="stat-label">Critical Supply</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Buttons -->
|
<!-- Filter Buttons -->
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<button
|
<button
|
||||||
v-for="f in filterOptions"
|
v-for="f in filterOptions"
|
||||||
:key="f.value"
|
:key="f.value"
|
||||||
class="btn"
|
class="btn"
|
||||||
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
||||||
@click="filter = f.value"
|
@click="filter = f.value"
|
||||||
>
|
>
|
||||||
{{ f.label }}
|
{{ f.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Printers Table -->
|
<!-- Printers Table -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table v-if="filteredPrinters.length">
|
<table v-if="filteredPrinters.length">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Printer Name</th>
|
<th>Printer Name</th>
|
||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
<th>Supplies</th>
|
<th>Supplies</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||||
<td>
|
<td>
|
||||||
<!-- Coordinates give the name the same floor-plan preview the
|
<!-- Coordinates give the name the same floor-plan preview the
|
||||||
printer's own page uses; it replaces the Location column. -->
|
printer's own page uses; it replaces the Location column. -->
|
||||||
<LocationMapTooltip
|
<LocationMapTooltip
|
||||||
v-if="printer.mapx != null && printer.mapy != null"
|
v-if="printer.mapx != null && printer.mapy != null"
|
||||||
:left="printer.mapx"
|
:left="printer.mapx"
|
||||||
:top="printer.mapy"
|
:top="printer.mapy"
|
||||||
:machineName="printer.printername || printer.assetnumber"
|
:levelid="printer.levelid"
|
||||||
>
|
:machineName="printer.printername || printer.assetnumber"
|
||||||
<router-link :to="`/printers/${printer.printerid}`">
|
>
|
||||||
{{ printer.printername || 'Unknown' }}
|
<router-link :to="`/printers/${printer.printerid}`">
|
||||||
</router-link>
|
{{ printer.printername || 'Unknown' }}
|
||||||
</LocationMapTooltip>
|
</router-link>
|
||||||
<router-link v-else :to="`/printers/${printer.printerid}`">
|
</LocationMapTooltip>
|
||||||
{{ printer.printername || 'Unknown' }}
|
<router-link v-else :to="`/printers/${printer.printerid}`">
|
||||||
</router-link>
|
{{ printer.printername || 'Unknown' }}
|
||||||
</td>
|
</router-link>
|
||||||
<td>
|
</td>
|
||||||
<!-- Opens the printer's own web page. New tab: the report is a
|
<td>
|
||||||
worklist, and losing your place in it to visit one printer
|
<!-- Opens the printer's own web page. New tab: the report is a
|
||||||
means finding your row again. -->
|
worklist, and losing your place in it to visit one printer
|
||||||
<a
|
means finding your row again. -->
|
||||||
v-if="fqdnFor(printer)"
|
<a
|
||||||
:href="`http://${fqdnFor(printer)}`"
|
v-if="fqdnFor(printer)"
|
||||||
target="_blank"
|
:href="`http://${fqdnFor(printer)}`"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
:title="printer.ipaddress"
|
rel="noopener noreferrer"
|
||||||
>{{ fqdnFor(printer) }}</a>
|
:title="printer.ipaddress"
|
||||||
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
>{{ fqdnFor(printer) }}</a>
|
||||||
</td>
|
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
||||||
<td class="supplies-cell">
|
</td>
|
||||||
<div
|
<td class="supplies-cell">
|
||||||
v-for="(supply, idx) in printer.supplies"
|
<div
|
||||||
:key="idx"
|
v-for="(supply, idx) in printer.supplies"
|
||||||
class="supply-row"
|
:key="idx"
|
||||||
>
|
class="supply-row"
|
||||||
<span class="supply-name">{{ supply.name }}</span>
|
>
|
||||||
<div class="supply-bar-track">
|
<span class="supply-name">{{ supply.name }}</span>
|
||||||
<div
|
<div class="supply-bar-track">
|
||||||
class="supply-bar-fill"
|
<div
|
||||||
:class="'supply-' + supply.status"
|
class="supply-bar-fill"
|
||||||
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
:class="'supply-' + supply.status"
|
||||||
></div>
|
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
||||||
</div>
|
></div>
|
||||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
</div>
|
||||||
{{ supply.level }}%
|
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||||
</span>
|
{{ supply.level }}%
|
||||||
<span class="supply-parts">
|
</span>
|
||||||
<span
|
<span class="supply-parts">
|
||||||
v-for="part in supply.partnumbers"
|
<span
|
||||||
:key="part.partnumber"
|
v-for="part in supply.partnumbers"
|
||||||
class="part-chip"
|
:key="part.partnumber"
|
||||||
:title="partTooltip(part)"
|
class="part-chip"
|
||||||
>{{ part.partnumber }}</span>
|
:title="partTooltip(part)"
|
||||||
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
>{{ part.partnumber }}</span>
|
||||||
class="part-none" title="No part number on file for this model and color">-</span>
|
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
||||||
</span>
|
class="part-none" title="No part number on file for this model and color">-</span>
|
||||||
</div>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
</table>
|
||||||
</div>
|
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
<script setup>
|
||||||
import { printersApi } from '@/api'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
import { printersApi } from '@/api'
|
||||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
const emailColumns = [
|
|
||||||
{ key: 'printer', label: 'Printer' },
|
const emailColumns = [
|
||||||
{ key: 'hostname', label: 'Hostname' },
|
{ key: 'printer', label: 'Printer' },
|
||||||
{ key: 'supply', label: 'Supply' },
|
{ key: 'hostname', label: 'Hostname' },
|
||||||
{ key: 'partnumber', label: 'Part Number' },
|
{ key: 'supply', label: 'Supply' },
|
||||||
{ key: 'level', label: 'Level' },
|
{ key: 'partnumber', label: 'Part Number' },
|
||||||
{ key: 'status', label: 'Status' },
|
{ key: 'level', label: 'Level' },
|
||||||
]
|
{ key: 'status', label: 'Status' },
|
||||||
|
]
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref(null)
|
const loading = ref(true)
|
||||||
const printers = ref([])
|
const error = ref(null)
|
||||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
const printers = ref([])
|
||||||
const filter = ref('all')
|
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||||
|
const filter = ref('all')
|
||||||
const filterOptions = [
|
|
||||||
{ label: 'All', value: 'all' },
|
const filterOptions = [
|
||||||
{ label: 'Critical', value: 'critical' },
|
{ label: 'All', value: 'all' },
|
||||||
{ label: 'Low', value: 'low' }
|
{ label: 'Critical', value: 'critical' },
|
||||||
]
|
{ label: 'Low', value: 'low' }
|
||||||
|
]
|
||||||
// Printers have no stored FQDN; the site builds one from the IP via the
|
|
||||||
// printer_hostname_template setting, the same way PrinterForm does.
|
// Printers have no stored FQDN; the site builds one from the IP via the
|
||||||
const hostnameTemplate = ref('')
|
// printer_hostname_template setting, the same way PrinterForm does.
|
||||||
|
const hostnameTemplate = ref('')
|
||||||
function fqdnFor(printer) {
|
|
||||||
if (!printer.ipaddress || !hostnameTemplate.value) return ''
|
function fqdnFor(printer) {
|
||||||
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
|
if (!printer.ipaddress || !hostnameTemplate.value) return ''
|
||||||
}
|
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
|
||||||
|
}
|
||||||
// A model can list several capacity tiers for one color, so the chip shows the
|
|
||||||
// part number and the tooltip says which one it is.
|
// A model can list several capacity tiers for one color, so the chip shows the
|
||||||
function partTooltip(part) {
|
// part number and the tooltip says which one it is.
|
||||||
const bits = [part.marketingname, part.capacitytier]
|
function partTooltip(part) {
|
||||||
if (part.pageyield) bits.push(part.pageyield + ' pages')
|
const bits = [part.marketingname, part.capacitytier]
|
||||||
return bits.filter(Boolean).join(' - ')
|
if (part.pageyield) bits.push(part.pageyield + ' pages')
|
||||||
}
|
return bits.filter(Boolean).join(' - ')
|
||||||
|
}
|
||||||
// One flat row per part number so an ordering list is copy-pasteable; supplies
|
|
||||||
// with no part on file still get a row.
|
// One flat row per part number so an ordering list is copy-pasteable; supplies
|
||||||
function reportRows() {
|
// with no part on file still get a row.
|
||||||
const rows = []
|
function reportRows() {
|
||||||
for (const printer of filteredPrinters.value) {
|
const rows = []
|
||||||
for (const supply of printer.supplies || []) {
|
for (const printer of filteredPrinters.value) {
|
||||||
const parts = supply.partnumbers && supply.partnumbers.length
|
for (const supply of printer.supplies || []) {
|
||||||
? supply.partnumbers.map(p => p.partnumber)
|
const parts = supply.partnumbers && supply.partnumbers.length
|
||||||
: ['']
|
? supply.partnumbers.map(p => p.partnumber)
|
||||||
for (const partnumber of parts) {
|
: ['']
|
||||||
rows.push({
|
for (const partnumber of parts) {
|
||||||
printer: printer.printername || '',
|
rows.push({
|
||||||
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
printer: printer.printername || '',
|
||||||
supply: supply.name || '',
|
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
||||||
partnumber: partnumber,
|
supply: supply.name || '',
|
||||||
level: supply.level,
|
partnumber: partnumber,
|
||||||
status: supply.status || '',
|
level: supply.level,
|
||||||
})
|
status: supply.status || '',
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows
|
}
|
||||||
}
|
return rows
|
||||||
|
}
|
||||||
const filteredPrinters = computed(() => {
|
|
||||||
if (filter.value === 'all') return printers.value
|
const filteredPrinters = computed(() => {
|
||||||
return printers.value.filter(p =>
|
if (filter.value === 'all') return printers.value
|
||||||
p.supplies.some(s => s.status === filter.value)
|
return printers.value.filter(p =>
|
||||||
)
|
p.supplies.some(s => s.status === filter.value)
|
||||||
})
|
)
|
||||||
|
})
|
||||||
// Emailed table, honoring the active filter.
|
|
||||||
const emailRows = computed(() =>
|
// Emailed table, honoring the active filter.
|
||||||
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
const emailRows = computed(() =>
|
||||||
)
|
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||||
|
)
|
||||||
function exportCSV() {
|
|
||||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
function exportCSV() {
|
||||||
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
const rows = [header]
|
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||||
for (const row of reportRows()) {
|
const rows = [header]
|
||||||
rows.push(header.map(key => row[key]))
|
for (const row of reportRows()) {
|
||||||
}
|
rows.push(header.map(key => row[key]))
|
||||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
}
|
||||||
const link = document.createElement('a')
|
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
const link = document.createElement('a')
|
||||||
link.download = 'toner_report.csv'
|
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||||
link.click()
|
link.download = 'toner_report.csv'
|
||||||
URL.revokeObjectURL(link.href)
|
link.click()
|
||||||
}
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
onMounted(async () => {
|
||||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
try {
|
||||||
const response = await printersApi.lowSupplies()
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||||
const data = response.data.data
|
const response = await printersApi.lowSupplies()
|
||||||
printers.value = data.printers || []
|
const data = response.data.data
|
||||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
printers.value = data.printers || []
|
||||||
} catch (err) {
|
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||||
console.error('Error loading toner report:', err)
|
} catch (err) {
|
||||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
console.error('Error loading toner report:', err)
|
||||||
} finally {
|
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||||
loading.value = false
|
} finally {
|
||||||
}
|
loading.value = false
|
||||||
})
|
}
|
||||||
</script>
|
})
|
||||||
|
</script>
|
||||||
<style scoped>
|
|
||||||
.summary-bar {
|
<style scoped>
|
||||||
display: flex;
|
.summary-bar {
|
||||||
gap: 1.5rem;
|
display: flex;
|
||||||
margin-bottom: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
.summary-stat {
|
|
||||||
flex: 1;
|
.summary-stat {
|
||||||
text-align: center;
|
flex: 1;
|
||||||
padding: 1.25rem;
|
text-align: center;
|
||||||
}
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
.stat-value {
|
|
||||||
font-size: 2rem;
|
.stat-value {
|
||||||
font-weight: 700;
|
font-size: 2rem;
|
||||||
color: var(--text);
|
font-weight: 700;
|
||||||
}
|
color: var(--text);
|
||||||
|
}
|
||||||
.stat-label {
|
|
||||||
color: var(--text-light);
|
.stat-label {
|
||||||
margin-top: 0.25rem;
|
color: var(--text-light);
|
||||||
}
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
.stat-low .stat-value {
|
|
||||||
color: var(--warning);
|
.stat-low .stat-value {
|
||||||
}
|
color: var(--warning);
|
||||||
|
}
|
||||||
.stat-critical .stat-value {
|
|
||||||
color: var(--danger);
|
.stat-critical .stat-value {
|
||||||
}
|
color: var(--danger);
|
||||||
|
}
|
||||||
/* One grid for the whole cell, with each supply row contributing its cells
|
|
||||||
directly (display: contents). Cartridge names were being ellipsised inside a
|
/* One grid for the whole cell, with each supply row contributing its cells
|
||||||
fixed 120px column, which hid the very thing being reordered; a max-content
|
directly (display: contents). Cartridge names were being ellipsised inside a
|
||||||
column sizes to the longest name instead, and the columns still line up
|
fixed 120px column, which hid the very thing being reordered; a max-content
|
||||||
across the rows of one printer. */
|
column sizes to the longest name instead, and the columns still line up
|
||||||
.supplies-cell {
|
across the rows of one printer. */
|
||||||
min-width: 460px;
|
.supplies-cell {
|
||||||
display: grid;
|
min-width: 460px;
|
||||||
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
display: grid;
|
||||||
column-gap: 0.5rem;
|
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
||||||
row-gap: 0.35rem;
|
column-gap: 0.5rem;
|
||||||
align-items: center;
|
row-gap: 0.35rem;
|
||||||
}
|
align-items: center;
|
||||||
|
}
|
||||||
.supply-row {
|
|
||||||
display: contents;
|
.supply-row {
|
||||||
}
|
display: contents;
|
||||||
|
}
|
||||||
.supply-name {
|
|
||||||
font-size: 0.85rem;
|
.supply-name {
|
||||||
color: var(--text-light);
|
font-size: 0.85rem;
|
||||||
white-space: nowrap;
|
color: var(--text-light);
|
||||||
}
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.supply-bar-track {
|
|
||||||
height: 8px;
|
.supply-bar-track {
|
||||||
background: var(--border);
|
height: 8px;
|
||||||
border-radius: 4px;
|
background: var(--border);
|
||||||
overflow: hidden;
|
border-radius: 4px;
|
||||||
}
|
overflow: hidden;
|
||||||
|
}
|
||||||
.supply-bar-fill {
|
|
||||||
height: 100%;
|
.supply-bar-fill {
|
||||||
border-radius: 4px;
|
height: 100%;
|
||||||
transition: width 0.3s ease;
|
border-radius: 4px;
|
||||||
}
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
.supply-ok {
|
|
||||||
background: var(--success);
|
.supply-ok {
|
||||||
}
|
background: var(--success);
|
||||||
|
}
|
||||||
.supply-low {
|
|
||||||
background: var(--warning);
|
.supply-low {
|
||||||
}
|
background: var(--warning);
|
||||||
|
}
|
||||||
.supply-critical {
|
|
||||||
background: var(--danger);
|
.supply-critical {
|
||||||
}
|
background: var(--danger);
|
||||||
|
}
|
||||||
.supply-level {
|
|
||||||
min-width: 40px;
|
.supply-level {
|
||||||
text-align: right;
|
min-width: 40px;
|
||||||
font-size: 0.85rem;
|
text-align: right;
|
||||||
font-weight: 600;
|
font-size: 0.85rem;
|
||||||
}
|
font-weight: 600;
|
||||||
|
}
|
||||||
.supply-parts {
|
|
||||||
display: flex;
|
.supply-parts {
|
||||||
flex-wrap: wrap;
|
display: flex;
|
||||||
gap: 0.25rem;
|
flex-wrap: wrap;
|
||||||
}
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
.part-chip {
|
|
||||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
.part-chip {
|
||||||
font-size: 0.75rem;
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
padding: 0.05rem 0.35rem;
|
font-size: 0.75rem;
|
||||||
border: 1px solid var(--border);
|
padding: 0.05rem 0.35rem;
|
||||||
border-radius: 3px;
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
border-radius: 3px;
|
||||||
white-space: nowrap;
|
color: var(--text);
|
||||||
}
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.part-none {
|
|
||||||
font-size: 0.85rem;
|
.part-none {
|
||||||
color: var(--text-light);
|
font-size: 0.85rem;
|
||||||
}
|
color: var(--text-light);
|
||||||
|
}
|
||||||
.supply-text-ok {
|
|
||||||
color: var(--success);
|
.supply-text-ok {
|
||||||
}
|
color: var(--success);
|
||||||
|
}
|
||||||
.supply-text-low {
|
|
||||||
color: var(--warning);
|
.supply-text-low {
|
||||||
}
|
color: var(--warning);
|
||||||
|
}
|
||||||
.supply-text-critical {
|
|
||||||
color: var(--danger);
|
.supply-text-critical {
|
||||||
}
|
color: var(--danger);
|
||||||
|
}
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
.empty-state {
|
||||||
padding: 2rem;
|
text-align: center;
|
||||||
color: var(--text-light);
|
padding: 2rem;
|
||||||
}
|
color: var(--text-light);
|
||||||
|
}
|
||||||
.text-danger {
|
|
||||||
color: var(--danger);
|
.text-danger {
|
||||||
}
|
color: var(--danger);
|
||||||
|
}
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
.header-actions {
|
||||||
gap: 0.5rem;
|
display: flex;
|
||||||
}
|
gap: 0.5rem;
|
||||||
</style>
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
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"
|
v-if="machine && hasPosition"
|
||||||
:left="machine.mapx"
|
:left="machine.mapx"
|
||||||
:top="machine.mapy"
|
:top="machine.mapy"
|
||||||
|
:levelid="machine.levelid"
|
||||||
:machineName="machine.machinenumber || machine.name || ''"
|
:machineName="machine.machinenumber || machine.name || ''"
|
||||||
>
|
>
|
||||||
<span class="mmc-chip" :title="hoverTitle">
|
<span class="mmc-chip" :title="hoverTitle">
|
||||||
|
|||||||
@@ -169,21 +169,44 @@ fi
|
|||||||
# whether all of them travel with their level, because reading them by eye is
|
# whether all of them travel with their level, because reading them by eye is
|
||||||
# how the twentieth gets missed.
|
# how the twentieth gets missed.
|
||||||
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
|
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
|
||||||
POSITION_FILES=$(grep -rln "'mapx':" --include='*.py' shopdb/ plugins/ 2>/dev/null \
|
# PER OCCURRENCE, not per file. The file-level form passed a module that emitted
|
||||||
| grep -v '/tests\?/' || true)
|
# 'mapx' six times and 'levelid' once, and two payloads shipped without a level:
|
||||||
|
# the toner report and the enforcement report, both feeding a hover preview that
|
||||||
|
# then said "no level". Every 'mapx' must have a 'levelid' in the same literal -
|
||||||
|
# the window is wide enough for a comment between them, and no wider.
|
||||||
MISSING_LEVEL=""
|
MISSING_LEVEL=""
|
||||||
for candidate in $POSITION_FILES; do
|
while IFS= read -r hit; do
|
||||||
if ! grep -q "'levelid'" "$candidate"; then
|
[ -z "$hit" ] && continue
|
||||||
MISSING_LEVEL="$MISSING_LEVEL$candidate"$'\n'
|
file=${hit%%:*}
|
||||||
|
line=${hit#*:}; line=${line%%:*}
|
||||||
|
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'levelid'"; then
|
||||||
|
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
|
||||||
fi
|
fi
|
||||||
done
|
done <<EOF
|
||||||
|
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
|
||||||
|
EOF
|
||||||
if [ -n "$MISSING_LEVEL" ]; then
|
if [ -n "$MISSING_LEVEL" ]; then
|
||||||
echo "FAIL: these emit 'mapx' but never 'levelid' - a position with no level"
|
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
|
||||||
echo " cannot be rendered on the right drawing:"
|
echo " level cannot be drawn on the right floor plan (ADR-017):"
|
||||||
echo "$MISSING_LEVEL" | sed 's/^/ /'
|
echo "$MISSING_LEVEL" | sed 's/^/ /'
|
||||||
VIOLATIONS=$((VIOLATIONS + 1))
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The same rule for the hover preview: binding coordinates into
|
||||||
|
# LocationMapTooltip without a level makes it report "no level" for every asset,
|
||||||
|
# which is exactly what shipped in 0.11.0 - all seven call sites missed it.
|
||||||
|
TOOLTIP_MISSING=""
|
||||||
|
for candidate in $(grep -rl "LocationMapTooltip" --include='*.vue' frontend/src plugins/ 2>/dev/null | grep -v plugins-staged || true); do
|
||||||
|
grep -q ':left=' "$candidate" || continue
|
||||||
|
grep -q ':levelid=' "$candidate" || TOOLTIP_MISSING="$TOOLTIP_MISSING$candidate"$'\n'
|
||||||
|
done
|
||||||
|
if [ -n "$TOOLTIP_MISSING" ]; then
|
||||||
|
echo "FAIL: these bind LocationMapTooltip coordinates without :levelid, so the"
|
||||||
|
echo " preview cannot know which drawing to use (ADR-017):"
|
||||||
|
echo "$TOOLTIP_MISSING" | sed 's/^/ /'
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
# ENFORCING. It was report-only while the backlog was worked off, and the hit
|
# ENFORCING. It was report-only while the backlog was worked off, and the hit
|
||||||
# count then did not move for weeks - a rule that only prints is read as no rule.
|
# count then did not move for weeks - a rule that only prints is read as no rule.
|
||||||
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
|
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ class Harness:
|
|||||||
self.ids = IdMap(idmap_path or default)
|
self.ids = IdMap(idmap_path or default)
|
||||||
self.source = Source()
|
self.source = Source()
|
||||||
self.errors = []
|
self.errors = []
|
||||||
|
self._defaultlevelid = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultlevelid(self):
|
||||||
|
"""The level imported map positions belong to (ADR-017).
|
||||||
|
|
||||||
|
The legacy schema predates levels: it has ONE floor plan, so every
|
||||||
|
mapleft/maptop it carries is a coordinate on this site's default level.
|
||||||
|
Importing them without a level produces markers the map refuses to draw
|
||||||
|
- it will not guess a drawing for coordinates that do not name one.
|
||||||
|
"""
|
||||||
|
if self._defaultlevelid is None:
|
||||||
|
from shopdb.core.models import MapLevel
|
||||||
|
level = MapLevel.default_level()
|
||||||
|
self._defaultlevelid = level.levelid if level else None
|
||||||
|
return self._defaultlevelid
|
||||||
|
|
||||||
def _silence_sql_logging(self):
|
def _silence_sql_logging(self):
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ def stage_assets(h):
|
|||||||
'serialnumber': (m['serialnumber'] or '').strip() or None,
|
'serialnumber': (m['serialnumber'] or '').strip() or None,
|
||||||
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
||||||
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
||||||
|
'levelid': h.defaultlevelid,
|
||||||
'notes': m['machinenotes'],
|
'notes': m['machinenotes'],
|
||||||
'dateadded': str(m['dateadded']) if m['dateadded'] else None,
|
'dateadded': str(m['dateadded']) if m['dateadded'] else None,
|
||||||
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
|
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
|
||||||
@@ -345,6 +346,7 @@ def stage_printers(h):
|
|||||||
'iscsf': _truthy_bit(p['iscsf']),
|
'iscsf': _truthy_bit(p['iscsf']),
|
||||||
'installpath': p['installpath'], 'pin': p['printerpin'],
|
'installpath': p['installpath'], 'pin': p['printerpin'],
|
||||||
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
|
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
|
||||||
|
'levelid': h.defaultlevelid,
|
||||||
'modelnumberid': h.ids.get('model', p['modelid']),
|
'modelnumberid': h.ids.get('model', p['modelid']),
|
||||||
'locationid': h.ids.get('location', p['machineid']),
|
'locationid': h.ids.get('location', p['machineid']),
|
||||||
}
|
}
|
||||||
@@ -386,7 +388,8 @@ def stage_metrology(h):
|
|||||||
'name': (f"{pcname} {toolname}").strip() or toolname,
|
'name': (f"{pcname} {toolname}").strip() or toolname,
|
||||||
'measuringtooltypeid': typeid,
|
'measuringtooltypeid': typeid,
|
||||||
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
||||||
'mapx': m['mapleft'], 'mapy': m['maptop']})
|
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
||||||
|
'levelid': h.defaultlevelid})
|
||||||
if status not in (200, 201):
|
if status not in (200, 201):
|
||||||
continue
|
continue
|
||||||
tool_assetid = _id_of(data, 'assetid')
|
tool_assetid = _id_of(data, 'assetid')
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ def create_app(config_name: str = None) -> Flask:
|
|||||||
|
|
||||||
app.config.from_object(config_class)
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# The product and contract versions, in config so a PLUGIN can read them
|
||||||
|
# through current_app instead of importing core (ADR-002 forbids that, and
|
||||||
|
# the contract test enforces it). Core code may still use the module
|
||||||
|
# constants directly.
|
||||||
|
app.config['VERSION'] = __version__
|
||||||
|
app.config['CONTRACT_VERSION'] = __contract_version__
|
||||||
|
|
||||||
# Load instance config if exists
|
# Load instance config if exists
|
||||||
app.config.from_pyfile('config.py', silent=True)
|
app.config.from_pyfile('config.py', silent=True)
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
Reference in New Issue
Block a user