docs: write down the composition pattern, not just the one case
The MECHANISM was already a documented platform contract - ADR-001 defines partof as composition, makes controls propagate through it, and walks it first for map-position inheritance. The part-marker work used that rail rather than inventing one. What was undocumented is the PATTERN built on it: several devices answering to one identifier, each becoming its own asset filed under a parent. It existed only as a collector behaviour for part markers plus a docstring in the device map, so nothing told anyone how to apply it to another device type, or when not to. ASSET-COMPOSITION.md covers when to reach for it and when the shared identifier is a numbering fault instead, what propagation buys, how to declare a device type through the map or a per-site setting, what a backup kind must do to follow the device rather than the parent, how to find the next case with check-shared-machines, and why the parent is not disposable once devices hang off it - deactivating it breaks filing, and a hard delete cascades through backuprevisions.
This commit is contained in:
154
docs/ASSET-COMPOSITION.md
Normal file
154
docs/ASSET-COMPOSITION.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Composition: several devices under one parent
|
||||
|
||||
When more than one physical device answers to a single identifier, model each
|
||||
device as its own asset and file it under a parent. This page is the pattern:
|
||||
when to reach for it, what you get for free, and how to make the collector do
|
||||
it without writing code.
|
||||
|
||||
The mechanism is not new - `partof` and its propagation rails are the ADR-001
|
||||
platform contract. What is new is the recipe.
|
||||
|
||||
## The problem it solves
|
||||
|
||||
A machine number is supposed to identify one thing. Sometimes it does not.
|
||||
|
||||
At West Jefferson, several Telesis part markers serve one operation number:
|
||||
0613, 0615 and WJPRT each have more than one. Their configurations differ, most
|
||||
often by COM port. Treating the operation as the device collapsed them into a
|
||||
single record, and the damage was quiet:
|
||||
|
||||
- Their backups overwrote each other, so the history read as one device
|
||||
flip-flopping between configurations that were really two devices.
|
||||
- No question about an individual device could be asked at all - how many there
|
||||
are, which port one is on, which one failed.
|
||||
- Every PC driving one contested the single `controls` link to the operation,
|
||||
so whichever reported last appeared to own it.
|
||||
|
||||
None of that announced itself. It surfaced as duplicate backup rows, weeks
|
||||
later.
|
||||
|
||||
## When to use it
|
||||
|
||||
Reach for composition when ALL of these hold:
|
||||
|
||||
- Several physical devices share one identifier, and that is CORRECT - not a
|
||||
numbering mistake. Two PCs carrying the same machine number by accident is a
|
||||
fault to fix on the PC, not a model to build. `flask relationships
|
||||
check-shared-machines` tells the two apart.
|
||||
- The devices are individually interesting: they fail, get replaced, carry
|
||||
their own configuration or calibration.
|
||||
- Something already identifies each device. One device per PC is the easy case,
|
||||
because the PC names it.
|
||||
|
||||
Do NOT reach for it when the parent is simply a location. A room holding six
|
||||
printers wants a Location, not a parent asset.
|
||||
|
||||
## What you get
|
||||
|
||||
Filing a device `partof` a parent buys behaviour already built:
|
||||
|
||||
- **Control propagates.** `controls` propagates through `partof` (ADR-001, and
|
||||
seeded by `flask seed reference-data`). A PC controlling a device therefore
|
||||
controls its parent by inheritance, so the PC does NOT need - and must not
|
||||
have - a direct link to the parent. That is what stops several devices
|
||||
contesting a link only one can hold.
|
||||
- **Position inherits.** Map-position resolution walks `partof` first, so a
|
||||
device with no coordinates of its own shows at its parent's position.
|
||||
- **History separates.** Anything keyed on the asset - backup revisions,
|
||||
relationships, notes, audit - is per device instead of merged.
|
||||
|
||||
## Making the collector do it
|
||||
|
||||
A PC that drives a subordinate device declares it in
|
||||
`plugins/computers/pctypemap.py`:
|
||||
|
||||
```python
|
||||
SUBORDINATE_DEVICE_MAP = {
|
||||
'gea-shopfloor-partmarker': {
|
||||
'assettype': 'machine', # core AssetType for the device
|
||||
'typename': 'Part Marker', # type within that vocabulary
|
||||
'description': 'Telesis part marker',
|
||||
'suffix': 'PARTMARKER', # device asset number = <PC>-<suffix>
|
||||
'partof': True, # file under the reported machine number
|
||||
'label': 'collector:partmarker',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`partof: True` is the switch that matters. It files the device under the
|
||||
operation the PC reports AND stops that PC claiming the operation directly.
|
||||
Leave it False for a device that does not share an identifier - a CMM is a
|
||||
subordinate device too, but no two CMMs answer to one number, so it links to
|
||||
its PC and nothing more.
|
||||
|
||||
`label` is the relationship origin marker. Only rows carrying it are archived
|
||||
by a collector push, so links made by hand are never touched. **Do not change
|
||||
an existing label**: those exact strings are in production databases.
|
||||
|
||||
A site adds or retargets an entry without a code change, per ADR-015:
|
||||
|
||||
```
|
||||
Setting: subordinatedevice_<pctype> category: pctypemapping
|
||||
Value: {"assettype": "machine", "typename": "Marking Laser",
|
||||
"suffix": "LASER", "partof": true, "label": "collector:partmarker"}
|
||||
```
|
||||
|
||||
A malformed override falls back to the built-in default rather than failing the
|
||||
collector push - a bad setting must not stop a bay reporting its inventory.
|
||||
|
||||
## Making a backup kind follow the device
|
||||
|
||||
A backup posted by a PC that drives a device belongs to the DEVICE, not the
|
||||
parent. `plugins/backups/services/registry.py` resolves this through
|
||||
`markerforsource`: it finds the PC by the reported `sourcehostname`, follows the
|
||||
PC's active device link, and files against what it finds. It falls back to the
|
||||
machine number whenever the device cannot be resolved - no hostname on the
|
||||
payload, a lean build without the computers plugin, or a PC that has not
|
||||
reported yet - because filing under the parent beats rejecting a backup.
|
||||
|
||||
Two things that matter if you add a kind:
|
||||
|
||||
- **`sourcehostname` is load-bearing**, not informational. Without it a backup
|
||||
files against the parent and merges two devices' histories.
|
||||
- **A revision chain is (asset, kind, source hostname)**, so two devices on one
|
||||
parent keep separate chains even before they become separate assets.
|
||||
|
||||
## Finding the next one
|
||||
|
||||
```
|
||||
flask relationships check-shared-machines
|
||||
```
|
||||
|
||||
Read-only. Lists every machine number claimed by more than one PC and separates
|
||||
the two cases by whether child assets exist:
|
||||
|
||||
```
|
||||
0615 11 PCs, 11 child asset(s) - modelled
|
||||
2026 2 PCs, NO child assets - F31N20R3, F4Z7S7J4
|
||||
```
|
||||
|
||||
The first is composition working. The second is two PCs carrying one number -
|
||||
fix that on the PC.
|
||||
|
||||
## The parent is not disposable
|
||||
|
||||
Once devices are filed under it, the parent is doing a real job even though it
|
||||
may look empty: it is the thing that says those devices belong together.
|
||||
Deactivating it (`isactive = 0`, which is what "delete" does in the UI) breaks
|
||||
filing, because the resolver requires an ACTIVE parent - every subsequent
|
||||
report warns and files nothing.
|
||||
|
||||
A hard SQL `DELETE` is worse: `backuprevisions.assetid` is `ON DELETE CASCADE`,
|
||||
so it destroys history still attached to the parent.
|
||||
|
||||
If the parent looks wrong in a list - an operation number appearing among
|
||||
machines - that is a CLASSIFICATION question, not a deletion one.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/adr/ADR-001-asset-as-platform-contract.md` - relationship types,
|
||||
propagation rails, position inheritance
|
||||
- `docs/adr/ADR-015-site-specific-configuration.md` - why the device map is
|
||||
settings-overridable
|
||||
- `docs/COLLECTOR-INTEGRATION.md` - the collector contract and the part-marker
|
||||
case end to end
|
||||
@@ -246,6 +246,11 @@ A PC takes its hostname as its asset number and keeps it.
|
||||
several PCs on one machine number legitimately, and there the alerts fire on
|
||||
correct data. Warnings ride in the collector response either way.
|
||||
|
||||
The part-marker case below is one instance of a general pattern - several
|
||||
devices under one parent. `docs/ASSET-COMPOSITION.md` covers when to use it,
|
||||
what propagation buys you, and how to declare a new device type without a code
|
||||
change.
|
||||
|
||||
A PC reporting `pctype = gea-shopfloor-partmarker` is handled differently,
|
||||
because several markers can serve one machine number and an operation holds any
|
||||
number of them. Such a PC gets its own Part Marker machine asset
|
||||
|
||||
506
docs/examples/toner-dashboard.html
Normal file
506
docs/examples/toner-dashboard.html
Normal file
@@ -0,0 +1,506 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ShopDB printer supplies API - reference and live dashboard example</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--card: #f6f7f9;
|
||||
--text: #16181d;
|
||||
--muted: #5b6472;
|
||||
--border: #d8dce3;
|
||||
--accent: #0d6efd;
|
||||
--ok: #04b962;
|
||||
--low: #ff8800;
|
||||
--critical: #f5365c;
|
||||
--code: #eef1f5;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d0d1f;
|
||||
--card: #16162e;
|
||||
--text: #e8eaf0;
|
||||
--muted: #9aa3b5;
|
||||
--border: #2a2a4a;
|
||||
--code: #10102a;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { max-width: 62rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.9rem; margin: 0 0 .3rem; }
|
||||
h2 { font-size: 1.35rem; margin: 2.5rem 0 .75rem; padding-top: .75rem; border-top: 1px solid var(--border); }
|
||||
h3 { font-size: 1.05rem; margin: 1.5rem 0 .4rem; }
|
||||
.lede { color: var(--muted); margin: 0 0 1.5rem; }
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
code { background: var(--code); padding: .12em .38em; border-radius: 4px; font-size: .9em; }
|
||||
pre {
|
||||
background: var(--code);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
font-size: .86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
pre code { background: none; padding: 0; font-size: inherit; }
|
||||
table { border-collapse: collapse; width: 100%; margin: .75rem 0; font-size: .93rem; }
|
||||
th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
td code { white-space: nowrap; }
|
||||
.note {
|
||||
background: var(--card);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: .8rem 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.note.warn { border-left-color: var(--low); }
|
||||
.pill { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
|
||||
.pill.ok { background: var(--ok); color: #04220f; }
|
||||
.pill.low { background: var(--low); color: #2b1600; }
|
||||
.pill.critical { background: var(--critical); color: #fff; }
|
||||
|
||||
/* ---- live demo ---- */
|
||||
.demo { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; }
|
||||
.demo-controls { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
|
||||
.demo input {
|
||||
flex: 1 1 22rem; min-width: 14rem; padding: .45rem .6rem;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--bg); color: var(--text); font: inherit; font-size: .9rem;
|
||||
}
|
||||
.demo button {
|
||||
padding: .45rem .9rem; border: 0; border-radius: 6px;
|
||||
background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.summary { display: flex; gap: 1.25rem; flex-wrap: wrap; margin-bottom: 1rem; color: var(--muted); font-size: .9rem; }
|
||||
.summary b { color: var(--text); font-size: 1.3rem; display: block; }
|
||||
.printer { border-top: 1px solid var(--border); padding: .75rem 0; }
|
||||
.printer-name { font-weight: 600; }
|
||||
.printer-meta { color: var(--muted); font-size: .85rem; }
|
||||
.supply { display: grid; grid-template-columns: 11rem 1fr 4rem; gap: .6rem; align-items: center; margin: .35rem 0; font-size: .88rem; }
|
||||
.bar { background: var(--border); border-radius: 999px; height: .55rem; overflow: hidden; }
|
||||
.bar span { display: block; height: 100%; border-radius: 999px; }
|
||||
.printer-rest { color: var(--muted); font-size: .8rem; margin-top: .35rem; }
|
||||
.status-msg { color: var(--muted); font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>Printer supplies API</h1>
|
||||
<p class="lede">
|
||||
Reference for the toner and supply endpoints in ShopDB, and a working
|
||||
single-page dashboard you can lift straight out of this file.
|
||||
</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Where the numbers come from.</strong> ShopDB does not poll printers itself.
|
||||
Levels are read from Zabbix by IP address, so every endpoint below returns empty
|
||||
results unless <code>zabbix_enabled</code>, <code>zabbix_url</code> and
|
||||
<code>zabbix_token</code> are set under Settings > Integrations. That failure is
|
||||
deliberately soft: you get <code>200</code> with an empty list, never an error, so a
|
||||
wall dashboard does not blow up when Zabbix is down.
|
||||
</div>
|
||||
|
||||
<h2>Endpoints</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Method and path</th><th>Auth</th><th>What it returns</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>GET /api/printers/lowsupplies</code></td>
|
||||
<td>optional</td>
|
||||
<td>Every printer with at least one supply below threshold, plus a summary. The toner report. Cached 5 minutes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /api/printers/<printerid>/supplies</code></td>
|
||||
<td>optional</td>
|
||||
<td>Live levels for one printer, read from Zabbix on request. Not cached.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /api/printers/supplies/refresh</code></td>
|
||||
<td><strong>required</strong> + <code>printers.create</code></td>
|
||||
<td>Clears the cache so the next read is fresh. Backs the report's Refresh button.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /api/printers/supplies/meta</code></td>
|
||||
<td>optional</td>
|
||||
<td>Allowed supply types, colors and capacity tiers.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /api/printers/models/<modelnumberid>/supplies</code></td>
|
||||
<td>optional</td>
|
||||
<td>Cartridge part numbers for a model.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /api/printers/lookup?ip=</code> or <code>?fqdn=</code></td>
|
||||
<td>optional</td>
|
||||
<td>Resolve an IP or hostname to a printer id.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
"optional" means the endpoint accepts a token but does not need one: a logged-out
|
||||
page can read it. Only the refresh endpoint requires a token, and the account
|
||||
behind it needs the <code>printers.create</code> permission.
|
||||
</p>
|
||||
|
||||
<h2>Response shape</h2>
|
||||
|
||||
<p>Every ShopDB response is wrapped. Your data is under <code>data</code>:</p>
|
||||
|
||||
<pre><code>{
|
||||
"status": "success",
|
||||
"data": { ... },
|
||||
"meta": { "requestid": "6d9d0bb6", "timestamp": "2026-08-11T13:47:41.250953Z" }
|
||||
}</code></pre>
|
||||
|
||||
<h3>GET /api/printers/lowsupplies</h3>
|
||||
|
||||
<pre><code>{
|
||||
"printers": [
|
||||
{
|
||||
"printerid": 42,
|
||||
"printername": "Printer-10-129-22-15",
|
||||
"assetnumber": "PRN-0042",
|
||||
"ipaddress": "10.129.22.15",
|
||||
"vendor": "Xerox",
|
||||
"model": "AltaLink C8145",
|
||||
"location": "Building 1 - Cell 4",
|
||||
"supplies": [
|
||||
{
|
||||
"name": "Cyan Toner Cartridge",
|
||||
"level": 8,
|
||||
"remaining": 8.0,
|
||||
"status": "low",
|
||||
"color": "cyan",
|
||||
"supplytype": "toner",
|
||||
"iswaste": false,
|
||||
"isdrum": false,
|
||||
"partnumbers": ["006R01737"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": { "total_checked": 37, "low": 4, "critical": 1 }
|
||||
}</code></pre>
|
||||
|
||||
<p>
|
||||
Only printers with something below threshold appear in <code>printers</code>.
|
||||
<code>total_checked</code> counts every printer Zabbix answered for, so
|
||||
"37 checked, 5 listed" means 32 are healthy. A printer is counted once in the
|
||||
summary, as <code>critical</code> if any of its supplies is critical, otherwise
|
||||
as <code>low</code>.
|
||||
</p>
|
||||
|
||||
<h3>GET /api/printers/<printerid>/supplies</h3>
|
||||
|
||||
<pre><code>{
|
||||
"ipaddress": "10.129.22.15",
|
||||
"pingstatus": "1",
|
||||
"supplies": [ /* same supply objects as above */ ]
|
||||
}</code></pre>
|
||||
|
||||
<p>
|
||||
<code>pingstatus</code> is Zabbix's reachability value, <code>"-1"</code> when
|
||||
Zabbix is unconfigured or unreachable. This endpoint hits Zabbix on every call,
|
||||
so poll it for one printer on a detail view, not for forty on a dashboard - use
|
||||
<code>lowsupplies</code> for that.
|
||||
</p>
|
||||
|
||||
<h2>The supply object</h2>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Meaning</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>name</code></td><td>Raw name from the printer, e.g. <code>"Black Toner Cartridge"</code>.</td></tr>
|
||||
<tr><td><code>level</code></td><td>Raw value as reported. For most waste cartridges this is percent <em>full</em>.</td></tr>
|
||||
<tr><td><code>remaining</code></td><td>Normalised percent remaining. <strong>Use this one</strong> for bars and text.</td></tr>
|
||||
<tr><td><code>status</code></td><td><span class="pill ok">ok</span> <span class="pill low">low</span> <span class="pill critical">critical</span></td></tr>
|
||||
<tr><td><code>color</code></td><td><code>black</code>, <code>cyan</code>, <code>magenta</code>, <code>yellow</code>, <code>none</code>.</td></tr>
|
||||
<tr><td><code>supplytype</code></td><td><code>toner</code>, <code>drum</code>, <code>waste</code>, <code>maintenance</code>.</td></tr>
|
||||
<tr><td><code>iswaste</code>, <code>isdrum</code></td><td>Convenience flags; a waste cartridge is usually worth showing differently.</td></tr>
|
||||
<tr><td><code>partnumbers</code></td><td>Order codes from the <code>modelsupplies</code> table. Empty until someone fills them in for that model.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="note warn">
|
||||
<strong>Do not compute status from <code>level</code> yourself.</strong>
|
||||
Thresholds are <code>remaining <= 5</code> critical, <code><= 10</code> low.
|
||||
The catch is direction: a full waste cartridge is bad, so for waste ShopDB
|
||||
converts to <code>100 - level</code> - except on Xerox, which already reports
|
||||
waste as capacity remaining. That vendor rule is why <code>remaining</code> and
|
||||
<code>status</code> exist. Read them, do not re-derive them.
|
||||
</div>
|
||||
|
||||
<h2>Before you build</h2>
|
||||
|
||||
<h3>Base path</h3>
|
||||
<p>
|
||||
Instances are served under a subpath, so the API is at
|
||||
<code>https://tsgwp00525.wjs.geaerospace.net/shopdb/api/...</code> - production on
|
||||
this server - or <code>/ops/api/...</code> for the dev instance beside it, not at
|
||||
the domain root. Make the base a variable; do not hardcode <code>/api</code>.
|
||||
</p>
|
||||
|
||||
<h3>Same origin, or CORS</h3>
|
||||
<p>
|
||||
The simplest deployment is to drop your HTML file into the instance's web root so
|
||||
it is served from the same origin - then <code>fetch</code> just works. A page
|
||||
served from anywhere else is a cross-origin request, and the browser will block it
|
||||
unless that origin is in the server's <code>CORS_ORIGINS</code> allowlist
|
||||
(an env var; production refuses to start with a wildcard).
|
||||
</p>
|
||||
|
||||
<h3>Caching and polling</h3>
|
||||
<p>
|
||||
<code>lowsupplies</code> is cached for 5 minutes server-side, so polling every
|
||||
30 seconds gets you the same payload nine times out of ten and buys nothing.
|
||||
Poll every 2 to 5 minutes. If you need a manual Refresh button, call the refresh
|
||||
endpoint first - that one needs a token.
|
||||
</p>
|
||||
|
||||
<h2>Minimal example</h2>
|
||||
|
||||
<pre><code>const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'; // no trailing /api
|
||||
|
||||
async function lowSupplies() {
|
||||
const response = await fetch(`${BASE}/api/printers/lowsupplies`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const body = await response.json();
|
||||
return body.data; // { printers, summary }
|
||||
}
|
||||
|
||||
lowSupplies().then(data => {
|
||||
console.log(`${data.summary.critical} critical, ${data.summary.low} low`);
|
||||
for (const printer of data.printers) {
|
||||
for (const supply of printer.supplies) {
|
||||
if (supply.status === 'ok') continue;
|
||||
console.log(`${printer.printername}: ${supply.name} ${supply.remaining}%`);
|
||||
}
|
||||
}
|
||||
});</code></pre>
|
||||
|
||||
<h2>Polling, with the failure cases handled</h2>
|
||||
|
||||
<pre><code>const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb';
|
||||
const POLL_MS = 3 * 60 * 1000; // server caches 5 min; faster buys nothing
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const response = await fetch(`${BASE}/api/printers/lowsupplies`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const { data } = await response.json();
|
||||
|
||||
// Zabbix off or unreachable: 200 with nothing in it. Say so, rather than
|
||||
// rendering "0 low" as if every printer were healthy.
|
||||
if (data.summary.total_checked === 0) {
|
||||
render.unavailable();
|
||||
} else {
|
||||
render.board(data);
|
||||
}
|
||||
} catch (err) {
|
||||
// A wall display must survive a blip: keep the last good render.
|
||||
console.error('supply poll failed', err);
|
||||
render.stale(err);
|
||||
} finally {
|
||||
setTimeout(tick, POLL_MS); // chained, so a slow response cannot pile up
|
||||
}
|
||||
}
|
||||
tick();</code></pre>
|
||||
|
||||
<h2>Refreshing on demand (needs a token)</h2>
|
||||
|
||||
<p>
|
||||
Create a Personal Access Token under Settings > API Tokens for an account with
|
||||
<code>printers.create</code>. Treat it as a credential: a token embedded in a page
|
||||
anyone can open is readable by anyone who opens it, so use this on an operator
|
||||
page behind login, not on a lobby display.
|
||||
</p>
|
||||
|
||||
<pre><code>async function refreshAndReload(token) {
|
||||
await fetch(`${BASE}/api/printers/supplies/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
return lowSupplies(); // next read pulls fresh values from Zabbix
|
||||
}</code></pre>
|
||||
|
||||
<h2>One printer, live</h2>
|
||||
|
||||
<pre><code>// Skips the cache and asks Zabbix now. Fine for a detail page, wrong for a loop.
|
||||
async function printerSupplies(printerid) {
|
||||
const response = await fetch(`${BASE}/api/printers/${printerid}/supplies`);
|
||||
const { data } = await response.json();
|
||||
if (data.pingstatus === '-1') return { offline: true, supplies: [] };
|
||||
return data;
|
||||
}
|
||||
|
||||
// Have an IP but not an id? Resolve it first.
|
||||
async function printerIdByIp(ip) {
|
||||
const response = await fetch(`${BASE}/api/printers/lookup?ip=${encodeURIComponent(ip)}`);
|
||||
const { data } = await response.json();
|
||||
return data?.printerid ?? null;
|
||||
}</code></pre>
|
||||
|
||||
<h2>Live dashboard</h2>
|
||||
|
||||
<p>
|
||||
The section below is the real thing, running in this page. Point it at an instance
|
||||
and it will render. Served from a different origin than the API, it will fail on
|
||||
CORS - that is the browser doing its job, and the fix is to host the file on the
|
||||
instance itself.
|
||||
</p>
|
||||
|
||||
<div class="demo">
|
||||
<div class="demo-controls">
|
||||
<input id="base" type="text" value="https://tsgwp00525.wjs.geaerospace.net/shopdb" aria-label="ShopDB base URL"
|
||||
placeholder="https://tsgwp00525.wjs.geaerospace.net/shopdb" />
|
||||
<button id="load">Load</button>
|
||||
</div>
|
||||
<div id="out"><p class="status-msg">Enter a base URL and press Load.</p></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---- the whole dashboard, in one place ----
|
||||
const COLORS = { ok: '#04b962', low: '#ff8800', critical: '#f5365c' };
|
||||
|
||||
const el = id => document.getElementById(id);
|
||||
const escapeHtml = text => String(text ?? '').replace(/[&<>"']/g,
|
||||
ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
|
||||
async function fetchLowSupplies(base) {
|
||||
const response = await fetch(`${base.replace(/\/$/, '')}/api/printers/lowsupplies`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
||||
const body = await response.json();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function supplyRow(supply) {
|
||||
// remaining, not level: waste cartridges and Xerox invert differently and the
|
||||
// server has already sorted that out.
|
||||
const width = Math.max(0, Math.min(100, supply.remaining));
|
||||
const parts = supply.partnumbers?.length
|
||||
? ` · ${escapeHtml(supply.partnumbers.join(', '))}` : '';
|
||||
return `
|
||||
<div class="supply">
|
||||
<div>${escapeHtml(supply.name)}${parts}</div>
|
||||
<div class="bar"><span style="width:${width}%;background:${COLORS[supply.status]}"></span></div>
|
||||
<div><span class="pill ${supply.status}">${supply.remaining}%</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function printerBlock(printer) {
|
||||
const meta = [printer.location, printer.model, printer.ipaddress]
|
||||
.filter(Boolean).map(escapeHtml).join(' · ');
|
||||
|
||||
// Only what needs action gets a row. A colour laser reports six supplies, so
|
||||
// rendering all of them buries the empty cartridge among five healthy ones
|
||||
// and costs the screen space of another printer.
|
||||
const failing = printer.supplies.filter(s => s.status !== 'ok');
|
||||
|
||||
// The healthy ones are still worth a glance - magenta at 12% is not flagged
|
||||
// today but is the same trip - so they collapse to one muted line rather
|
||||
// than disappearing.
|
||||
const healthy = printer.supplies.filter(s => s.status === 'ok');
|
||||
const rest = healthy.length
|
||||
? `<div class="printer-rest">also ok: ${healthy
|
||||
.map(s => `${escapeHtml(shortName(s))} ${Math.round(s.remaining)}%`)
|
||||
.join(' · ')}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="printer">
|
||||
<div class="printer-name">${escapeHtml(printer.printername || printer.assetnumber)}</div>
|
||||
<div class="printer-meta">${meta}</div>
|
||||
${failing.map(supplyRow).join('')}
|
||||
${rest}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 'Black Toner Cartridge' -> 'Black'. Enough to tell supplies apart on the
|
||||
// one-line summary without repeating 'Toner Cartridge' four times.
|
||||
function shortName(supply) {
|
||||
if (supply.iswaste) return 'Waste';
|
||||
if (supply.isdrum) return 'Drum';
|
||||
if (supply.color && supply.color !== 'none') {
|
||||
return supply.color.charAt(0).toUpperCase() + supply.color.slice(1);
|
||||
}
|
||||
return supply.name;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
if (data.summary.total_checked === 0) {
|
||||
// Zabbix off or unreachable. "0 low" here would be a lie, not good news.
|
||||
el('out').innerHTML =
|
||||
'<p class="status-msg">No printers could be checked - Zabbix is not configured or not reachable.</p>';
|
||||
return;
|
||||
}
|
||||
if (!data.printers.length) {
|
||||
el('out').innerHTML =
|
||||
`<p class="status-msg">All ${data.summary.total_checked} printers healthy.</p>`;
|
||||
return;
|
||||
}
|
||||
el('out').innerHTML = `
|
||||
<div class="summary">
|
||||
<div><b>${data.summary.critical}</b> critical</div>
|
||||
<div><b>${data.summary.low}</b> low</div>
|
||||
<div><b>${data.summary.total_checked}</b> checked</div>
|
||||
</div>
|
||||
${data.printers.map(printerBlock).join('')}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
el('out').innerHTML = '<p class="status-msg">Loading...</p>';
|
||||
try {
|
||||
render(await fetchLowSupplies(el('base').value));
|
||||
} catch (err) {
|
||||
el('out').innerHTML =
|
||||
`<p class="status-msg">Could not read supplies: ${escapeHtml(err.message)}. ` +
|
||||
'If this page is not served from the ShopDB instance, the browser blocked it on CORS.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
el('load').addEventListener('click', load);
|
||||
el('base').addEventListener('keydown', e => { if (e.key === 'Enter') load(); });
|
||||
</script>
|
||||
|
||||
<h2>Notes for a wall display</h2>
|
||||
<ul>
|
||||
<li>Show the supplies that need action as rows, and collapse the healthy ones
|
||||
into a single muted line. The endpoint returns every supply for a listed
|
||||
printer, so this is the client's call: a colour laser reports six, and
|
||||
rendering all six buries the empty one. Keep the healthy summary rather
|
||||
than dropping it - a cartridge at 12% is not flagged today but is the same
|
||||
walk.</li>
|
||||
<li>Chain the poll with <code>setTimeout</code> in a <code>finally</code>, not
|
||||
<code>setInterval</code>: a slow or hung request cannot then stack up behind itself.</li>
|
||||
<li>Keep the last good render when a poll fails. A display that blanks on one
|
||||
dropped request is worse than one showing five-minute-old numbers.</li>
|
||||
<li>Distinguish "nothing is low" from "nothing was checked". They both look like
|
||||
an empty list, and only one of them is good news.</li>
|
||||
<li>The board reloads itself if you serve it as a ShopDB display; otherwise add
|
||||
a daily <code>location.reload()</code> so a deployed fix reaches a screen
|
||||
nobody touches.</li>
|
||||
</ul>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user