status: consolidate the pill to one dot + one word; upgrade the panel

- pill: two host dots -> ONE consolidated state (down > slow > up) —
  the at-a-glance question is 'can I watch?', which is one state;
  per-host detail belongs in the tap panel
- panel: dashed slow-threshold line in the CDN sparkline (drawn at the
  server's real threshold, not a client copy) + origin up/down step
  sparkline so 'how long has the origin been down' is visible
- server: origin probe keeps a 20-sample hist like the CDN probe;
  DEGRADED threshold is now ANIWEB_CDN_KBPS (default 40) and is
  returned in /api/cdn so the UI draws the line at the live value
- tests: pill assertions switched to consolidated states; fixtures
  carry origin hist + threshold; assert both sparklines + threshold line
- docs: README + SPEC synced; preview on :8322 restarted (pid 44382)
This commit is contained in:
jozamudi committed 2026-09-21 00:10:10 +00:00
1 parent 778bf2bfc1
commit 5f7aa010f1
8 files changed
+105 -56

No files matched your search

+55 -30
View File
@@ -940,25 +940,27 @@ function statusPill() {
}
function renderPill(d) {
// ONE dot + one word: the at-a-glance question is "can I watch?", which is
// one state — down (origin dead) > slow (CDN degraded) > up. Per-host
// detail belongs in the tap panel, not here.
const p = document.getElementById("pill");
if (!p) return;
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : "";
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "";
p.className = "pill" + (o === "down" ? " odown" : "");
p.querySelector(".o").className = "o " + o;
p.querySelector(".c").className = "c " + c;
const known = (d.origin.state === "OK" || d.origin.state === "DOWN")
&& (d.state === "OK" || d.state === "DEGRADED");
const down = d.origin.state === "DOWN";
const slow = !down && d.state === "DEGRADED";
const st = !known ? "" : down ? "down" : slow ? "slow" : "ok";
p.className = "pill" + (st === "down" ? " down" : "");
p.querySelector(".d").className = "d" + (st ? " " + st : "");
p.querySelector("span").textContent =
o === "down" ? "upstream down"
: c === "degraded" ? "cdn " + d.kbps + " KB/s"
: "up";
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers)
p.querySelector(".o").title = "upstream site (primary provider pages/API): " + d.origin.state
+ (d.origin.msg ? " — " + d.origin.msg : "");
p.querySelector(".c").title = "video CDN throughput: " + d.state
+ " — " + d.kbps + " KB/s";
p.setAttribute("aria-label", "upstream site " + d.origin.state
+ (d.origin.msg ? ": " + d.origin.msg : "")
+ "; CDN " + d.state + ", " + d.kbps + " KB/s");
!st ? "…" : st === "down" ? "down"
: st === "slow" ? "slow · " + d.kbps + " KB/s" : "up";
p.title = "upstream site: " + d.origin.state + (d.origin.msg ? " — " + d.origin.msg : "")
+ " · video CDN: " + d.state + " — " + d.kbps + " KB/s";
p.setAttribute("aria-label", "status: " + (!st ? "checking"
: st === "down" ? "down — upstream " + d.origin.state
: st === "slow" ? "slow — CDN " + d.state + ", " + d.kbps + " KB/s"
: "up"));
}
function toggleStatusPanel() {
@@ -978,31 +980,54 @@ function renderStatusPanel(d) {
w.append(el("div", "sp-row", "upstream: " + d.origin.state
+ (d.origin.msg ? " (" + d.origin.msg + ")" : "")));
w.append(el("div", "sp-row", "CDN: " + d.state + " — " + d.kbps + " KB/s"));
const h = d.hist || [];
if (h.length > 1) {
w.append(sparkline(h));
w.append(el("div", "sp-cap", "last " + Math.round((h[h.length - 1].t - h[0].t) / 60) + " min"));
const ch = d.hist || [];
if (ch.length > 1) {
w.append(sparkline(ch.map(s => s.kbps), d.threshold));
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch)
+ " min (dashed = slow threshold " + Math.round(d.threshold) + " KB/s)"));
}
const oh = d.origin.hist || [];
if (oh.length > 1) {
const up = oh[oh.length - 1].state === "OK";
w.append(sparkline(oh.map(s => (s.state === "OK" ? 1 : 0)), null, true, up));
w.append(el("div", "sp-cap", "upstream, last " + _mins(oh) + " min (flat bottom = down)"));
}
const age = Math.max(0, Math.round((Date.now() / 1000 - d.ts) / 60));
w.append(el("div", "sp-cap", "updated " + age + " min ago"));
}
function sparkline(hist) {
// ~20 samples (3-min spacing) -> one inline-SVG polyline, scaled to the
// window max. No lib: it's a single polyline.
function _mins(h) { return Math.round((h[h.length - 1].t - h[0].t) / 60); }
function sparkline(values, threshold, binary = false, healthy = true) {
// ~20 samples (3-min spacing) -> one inline-SVG polyline, scaled so a
// dashed threshold line (when given) always fits. binary = up(1)/down(0)
// step line for the origin probe. No lib: it's a single polyline.
const W = 120, H = 28, PAD = 2;
const max = Math.max(...hist.map(s => s.kbps), 1);
const pts = hist.map((s, i) =>
(i / (hist.length - 1) * W).toFixed(1) + ","
+ (H - PAD - Math.min(s.kbps / max, 1) * (H - 2 * PAD)).toFixed(1)).join(" ");
const max = Math.max(Math.max(...values), threshold || 0, binary ? 1 : 0) || 1;
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.setAttribute("class", "sp-svg");
svg.setAttribute("class", "sp-svg" + (binary && !healthy ? " bad" : ""));
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", "CDN throughput trend, last hour");
svg.setAttribute("aria-label", binary ? "upstream up/down, last hour"
: "CDN throughput trend, last hour");
if (threshold) {
const ty = Math.max(PAD, H - PAD - Math.min(threshold / max, 1) * (H - 2 * PAD));
const l = document.createElementNS(NS, "line");
l.setAttribute("x1", 0); l.setAttribute("x2", W);
l.setAttribute("y1", ty.toFixed(1)); l.setAttribute("y2", ty.toFixed(1));
l.setAttribute("class", "sp-thr");
svg.append(l);
}
const pts = [];
values.forEach((v, i) => {
const x0 = (i / (values.length - 1)) * W;
const y = H - PAD - Math.min(v / max, 1) * (H - 2 * PAD);
pts.push(x0.toFixed(1) + "," + y.toFixed(1));
if (binary) pts.push(((i + 1) / (values.length - 1) * W).toFixed(1) + "," + y.toFixed(1));
});
const pl = document.createElementNS(NS, "polyline");
pl.setAttribute("points", pts);
pl.setAttribute("points", pts.join(" "));
svg.append(pl);
return svg;
}
+1 -1
View File
@@ -18,7 +18,7 @@
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
<input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
</form>
<span id="pill" class="pill"><i class="o"></i><i class="c"></i><span></span></span>
<span id="pill" class="pill"><i class="d"></i><span></span></span>
<span id="me" class="muted"></span>
<div id="menubox" style="display:none">
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
+7 -5
View File
@@ -176,12 +176,14 @@ header .pill { display: flex; align-items: center; gap: .4rem; flex: none;
.sp-row { font-variant-numeric: tabular-nums; }
.sp-cap { color: var(--muted); font-size: .68rem; }
.sp-svg { width: 100%; height: 28px; color: var(--acc); }
.sp-svg.bad { color: #ef4444; } /* origin currently down */
.sp-svg polyline { fill: none; stroke: currentColor; stroke-width: 1.5; }
header .pill i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
header .pill .o.ok, header .pill .c.ok { background: #22c55e; }
header .pill .c.degraded { background: #f59e0b; }
header .pill .o.down { background: #ef4444; }
header .pill.odown { border-color: #7f1d1d; }
.sp-thr { stroke: #f59e0b; stroke-width: 1; stroke-dasharray: 3 3; }
header .pill .d { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
header .pill .d.ok { background: #22c55e; }
header .pill .d.slow { background: #f59e0b; }
header .pill .d.down { background: #ef4444; }
header .pill.down { border-color: #7f1d1d; }
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }