status: consolidate CDN/upstream status into the header pill + tap panel

- pill label now shows KB/s when degraded ('cdn 23 KB/s'); tap opens a
  panel (replaces the transient toast) with origin/CDN state, KB/s,
  a sparkline of the ~1h /api/cdn hist, and last-updated age
- 60s poll while the page is visible (was: load/play/failure only)
- playback failure overlay appends CDN context when degraded/down
- buffer gauge + spinner untouched: client buffer state, not CDN state
- server unchanged: /api/cdn already returned everything
- tests: 3 hermetic browser tests (mocked /api/cdn via page.route —
  the throwaway server's probe runs live upstream, so its data is
  mocked out of the assertions); 37 passed
- docs: README + SPEC synced
This commit is contained in:
jozamudi committed 2026-09-20 23:53:47 +00:00
1 parent 63a677f028
commit 778bf2bfc1
5 files changed
+235 -37

No files matched your search

+103 -36
View File
@@ -882,7 +882,17 @@ function playerFail(msg) {
savePos(lastSeenPos);
const { ep_id, ep_no } = currentEp;
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang, ua: navigator.userAgent});
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
playerOverlay("Playback failed: " + msg + cdnContext(), () => play(ep_id, ep_no));
}
function cdnContext() {
// one-line "why" on the failure overlay: playback failing while the CDN
// or upstream is bad is probably the network, not the browser
if (!_status) return "";
const bits = [];
if (_status.state !== "OK") bits.push("CDN " + _status.state + " (" + _status.kbps + " KB/s)");
if (_status.origin.state !== "OK") bits.push("upstream " + _status.origin.state);
return bits.length ? " · " + bits.join(", ") : "";
}
// ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
@@ -910,46 +920,103 @@ function bufferGauge() {
bufTimer = setInterval(tick, 250);
}
// ---------- status pill + panel: the ONE CDN/upstream status surface ----------
// The header pill (dots + short label) is visible on every screen — if the
// upstream is down you never reach the player, so status lives in the
// (sticky) header. Tap expands a panel with detail + the ~1h trend from
// /api/cdn's hist. The buffer gauge under the video is CLIENT buffer state,
// deliberately not mixed in here.
let _status = null;
function statusPill() {
// header badge: upstream site + CDN status from /api/cdn. Lives in the
// (sticky) header so it's visible on the home screen — if the upstream is
// down you never reach the player, so that's where the status must live.
api("/api/cdn").then(d => {
_status = d;
renderPill(d);
if (document.getElementById("status-panel")) renderStatusPanel(d);
}).catch(() => {
const p = document.getElementById("pill");
if (p) p.querySelector("span").textContent = "?";
});
}
function renderPill(d) {
const p = document.getElementById("pill");
if (!p) return;
api("/api/cdn").then(d => {
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" : "");
const od = p.querySelector(".o"), cd = p.querySelector(".c");
od.className = "o " + o;
cd.className = "c " + c;
p.querySelector("span").textContent =
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up";
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers)
od.title = "upstream site (anidb.app pages + API): " + d.origin.state
+ (d.origin.msg ? " — " + d.origin.msg : "");
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state
+ " — " + d.kbps + " KB/s";
p.title = ""; // dot-level titles carry the detail; pill-level would shadow them
p.setAttribute("aria-label", "upstream site " + d.origin.state
+ (d.origin.msg ? ": " + d.origin.msg : "")
+ "; CDN " + d.state + ", " + d.kbps + " KB/s");
// tap/click = tooltip for touch screens (mobile has no hover);
// read p._data at click time so the toast reflects the LATEST status,
// not the one captured when the handler was first bound
if (!p._bound) {
p._bound = 1;
p.onclick = e => {
e.stopPropagation();
const d = p._data;
toast("upstream site: " + d.origin.state + (d.origin.msg ? " (" + d.origin.msg + ")" : "")
+ " · CDN: " + d.state + " (" + d.kbps + " KB/s)");
};
}
p._data = d;
}).catch(() => { p.querySelector("span").textContent = "?"; });
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;
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");
}
function toggleStatusPanel() {
const open = document.getElementById("status-panel");
if (open) { open.remove(); return; }
const w = el("div", "status-panel");
w.id = "status-panel";
document.getElementById("pill").append(w);
if (_status) renderStatusPanel(_status);
else { w.append(el("div", "sp-row", "fetching…")); statusPill(); }
}
function renderStatusPanel(d) {
const w = document.getElementById("status-panel");
if (!w) return;
w.replaceChildren();
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 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.
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 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("role", "img");
svg.setAttribute("aria-label", "CDN throughput trend, last hour");
const pl = document.createElementNS(NS, "polyline");
pl.setAttribute("points", pts);
svg.append(pl);
return svg;
}
// pill = tap-to-toggle (mobile has no hover); re-tap or click-away closes.
// 60s poll while visible: the probe runs every 3 min and this endpoint is
// in-memory + local, so a fresh render is free.
document.getElementById("pill").onclick = e => { e.stopPropagation(); toggleStatusPanel(); };
document.addEventListener("click", () => {
const w = document.getElementById("status-panel");
if (w) w.remove();
});
setInterval(() => { if (document.visibilityState === "visible") statusPill(); }, 60000);
// ---------- auto-next on episode end (5s countdown) ----------
let nextTimer = null;
function onEnded() {
+10 -1
View File
@@ -163,11 +163,20 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); }
background: rgba(255, 255, 255, .1); }
.pl-buffer i { display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; }
/* header status pill: upstream site dot + CDN dot + short label */
/* header status pill: upstream site dot + CDN dot + short label; tap = panel */
header .pill { display: flex; align-items: center; gap: .4rem; flex: none;
position: relative; /* anchors the status panel */
font: 600 .72rem/1 system-ui, sans-serif; padding: .3rem .55rem; border-radius: 999px;
border: 1px solid var(--bd); background: var(--card); color: var(--fg);
cursor: help; }
.status-panel { position: absolute; top: 100%; right: 0; z-index: 6; margin-top: .35rem;
padding: .5rem .65rem; border-radius: 8px; border: 1px solid var(--bd);
background: var(--card); color: var(--fg); font-size: .78rem; min-width: 170px;
display: flex; flex-direction: column; gap: .3rem; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
.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 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; }