Files
shopdb-flask/docs/examples/toner-dashboard.html
cproudlock db2b9280e7
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 12:04:35 -04:00

507 lines
20 KiB
HTML

<!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 &gt; 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/&lt;printerid&gt;/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/&lt;modelnumberid&gt;/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/&lt;printerid&gt;/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 &lt;= 5</code> critical, <code>&lt;= 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 =&gt; {
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 &gt; 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
? ` &middot; ${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(' &middot; ');
// 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(' &middot; ')}</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>