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:
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