The scanner has been reporting the same count for weeks, which is what a rule that only prints becomes. It now FAILS the build, and it looks where the leaks actually were: PowerShell, the installer, the seeds, generated JSON, the frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools. A line that is deliberate declares itself with an ADR-015-OK marker and a reason, so the claim is visible in review instead of tolerated in silence. What it found, fixed here: - The shadow client wrote one site's ShopDB URL into HKLM whenever the registry disagreed. At the site it was written for that reads as healing drift; anywhere else it overwrites the site's own address on every enforce cycle, and the site cannot win because the cycle repeats. The bay's value now wins, an explicit -BaseUrl seeds it, and with neither there is nothing honest to write, so it says so and skips. - The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a kiosk elsewhere quietly opened a server it has no business reaching. The fallback is now this site's site_base_url, baked in at seed time, and the dispatcher refuses rather than guessing when neither is set. Its legacy shortcut matcher derives the host from that URL instead of naming one. - The OpenAPI generator hardcoded a production hostname into every spec it generated, which then published to a public wiki. The relative mount is the only server it can honestly name; a site passes its own by environment. - Placeholders and examples in the UI and the client help offered real internal subnets and a real production URL. They now use documentation ranges. Both publication gates - the export scrub and the docs publishability test - carry the site patterns, which neither did. One plant's hostname, FQDN and internal networks are out of the documentation and the generated specs. Comments naming the reference site are reworded rather than deleted: the reasoning is worth keeping, the plant name is not what makes it true.
507 lines
19 KiB
HTML
507 lines
19 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 > 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://shopdb.example.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://shopdb.example.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://shopdb.example.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://shopdb.example.net/shopdb" aria-label="ShopDB base URL"
|
|
placeholder="https://shopdb.example.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>
|