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:
1 parent
778bf2bfc1
commit
5f7aa010f1
8 files changed
+105
-56
No files matched your search
+55
-30
@@ -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
@@ -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
@@ -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); } }
|
||||
|
||||
Reference in new issue
Block a user