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
+224 -26

No files matched your search

+4
View File
@@ -61,6 +61,10 @@ proxy is the way in. See [SPEC.md](SPEC.md) for the rest.
![detail](docs/img/detail.png) ![detail](docs/img/detail.png)
- **Status** — the header pill shows upstream/CDN health (dots +
label); tap opens a panel with detail + the ~1h throughput trend.
A failure overlay names the CDN when it's the likely culprit.
- **Stats** — `?stats`: watch minutes, episodes, streaks, sub/dub - **Stats** — `?stats`: watch minutes, episodes, streaks, sub/dub
split, weekday/per-day heatmaps, top-10 shows, top searches split, weekday/per-day heatmaps, top-10 shows, top searches
(30–365 day windows). (30–365 day windows).
+3
View File
@@ -82,6 +82,9 @@ an exception falls through to the next provider, a `None` from
position-event stream. position-event stream.
- `GET /api/cdn` → CDN + origin health (background 64KB probe every - `GET /api/cdn` → CDN + origin health (background 64KB probe every
3 min, ~1h history) — check whether "playback broken" is the CDN. 3 min, ~1h history) — check whether "playback broken" is the CDN.
The header status pill is the single UI surface for this (tap opens
a panel: origin/CDN state, KB/s, sparkline of `hist`); the playback
failure overlay appends CDN context when degraded/down.
- `GET /stream/{token}` → proxied HLS. Token = base64url of the - `GET /stream/{token}` → proxied HLS. Token = base64url of the
upstream URL (host-allowlisted against the providers' self-registered upstream URL (host-allowlisted against the providers' self-registered
CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8 CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8
+92 -25
View File
@@ -882,7 +882,17 @@ function playerFail(msg) {
savePos(lastSeenPos); savePos(lastSeenPos);
const { ep_id, ep_no } = currentEp; const { ep_id, ep_no } = currentEp;
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang, ua: navigator.userAgent}); 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 ---------- // ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
@@ -910,46 +920,103 @@ function bufferGauge() {
bufTimer = setInterval(tick, 250); 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() { function statusPill() {
// header badge: upstream site + CDN status from /api/cdn. Lives in the api("/api/cdn").then(d => {
// (sticky) header so it's visible on the home screen — if the upstream is _status = d;
// down you never reach the player, so that's where the status must live. 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"); const p = document.getElementById("pill");
if (!p) return; if (!p) return;
api("/api/cdn").then(d => {
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : ""; const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : "";
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : ""; const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "";
p.className = "pill" + (o === "down" ? " odown" : ""); p.className = "pill" + (o === "down" ? " odown" : "");
const od = p.querySelector(".o"), cd = p.querySelector(".c"); p.querySelector(".o").className = "o " + o;
od.className = "o " + o; p.querySelector(".c").className = "c " + c;
cd.className = "c " + c;
p.querySelector("span").textContent = p.querySelector("span").textContent =
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up"; o === "down" ? "upstream down"
: c === "degraded" ? "cdn " + d.kbps + " KB/s"
: "up";
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers) // per-dot hover tooltips (desktop) + full aria label (a11y / screen readers)
od.title = "upstream site (anidb.app pages + API): " + d.origin.state p.querySelector(".o").title = "upstream site (primary provider pages/API): " + d.origin.state
+ (d.origin.msg ? " — " + d.origin.msg : ""); + (d.origin.msg ? " — " + d.origin.msg : "");
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state p.querySelector(".c").title = "video CDN throughput: " + d.state
+ " — " + d.kbps + " KB/s"; + " — " + 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 p.setAttribute("aria-label", "upstream site " + d.origin.state
+ (d.origin.msg ? ": " + d.origin.msg : "") + (d.origin.msg ? ": " + d.origin.msg : "")
+ "; CDN " + d.state + ", " + d.kbps + " KB/s"); + "; 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 = "?"; }); 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) ---------- // ---------- auto-next on episode end (5s countdown) ----------
let nextTimer = null; let nextTimer = null;
function onEnded() { 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); } background: rgba(255, 255, 255, .1); }
.pl-buffer i { display: block; height: 100%; width: 0; .pl-buffer i { display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; } 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; 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; 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); border: 1px solid var(--bd); background: var(--card); color: var(--fg);
cursor: help; } 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 i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
header .pill .o.ok, header .pill .c.ok { background: #22c55e; } header .pill .o.ok, header .pill .c.ok { background: #22c55e; }
header .pill .c.degraded { background: #f59e0b; } header .pill .c.degraded { background: #f59e0b; }
+115
View File
@@ -0,0 +1,115 @@
"""Hermetic browser tests for the header status pill + panel.
/api/cdn is mocked with page.route — the throwaway server's probe thread
runs against LIVE upstream, so its real data must never leak into these
assertions. Everything else (login, home) hits the throwaway server, which
never touches the real data. Skips if playwright is absent.
"""
import asyncio
import json
import pytest
async_playwright = pytest.importorskip("playwright.async_api").async_playwright
BASE = "http://127.0.0.1:8399"
LAUNCH_ARGS = ["--autoplay-policy=no-user-gesture-required"]
POLL = 100
def _cdn(state, kbps, origin="OK"):
return {
"state": state, "kbps": kbps, "ts": 1750000000,
"hist": [{"t": 1750000000 - 180 * (10 - i), "kbps": 20 + i * 10} for i in range(11)],
"origin": {"state": origin, "msg": "http 200", "ts": 1750000000},
}
async def _open(pw, body):
"""logged-in home page with /api/cdn mocked. alice has no history, so
home renders without touching the live upstream."""
browser = await pw.chromium.launch(args=LAUNCH_ARGS)
page = await (await browser.new_context()).new_page()
await page.route("**/api/cdn",
lambda r: r.fulfill(status=200, body=json.dumps(body),
headers={"Content-Type": "application/json"}))
await page.goto(BASE + "/")
await page.wait_for_selector("input#pw")
await page.fill("#user", "alice")
await page.fill("#pw", "alicepw12345")
await page.keyboard.press("Enter")
await page.wait_for_selector("#q", timeout=30000)
return browser, page
def _js_click(page, sel):
# JS click: this playwright/chromium combo's actionability check
# ("element stable") hangs non-deterministically on header elements
return page.evaluate(f"document.querySelector({json.dumps(sel)}).click()")
def test_pill_ok():
asyncio.run(_pill_ok())
async def _pill_ok():
async with async_playwright() as pw:
browser, page = await _open(pw, _cdn("OK", 120))
try:
await page.wait_for_function(
"document.querySelector('#pill .c').className.includes('ok')",
timeout=5000, polling=POLL)
assert await page.text_content("#pill span") == "up"
# tap opens the panel: detail rows + sparkline with one point
# per hist sample
await _js_click(page, "#pill")
await page.wait_for_selector("#status-panel .sp-svg polyline")
pts = await page.get_attribute("#status-panel .sp-svg polyline", "points")
assert len(pts.split()) == 11
assert "CDN: OK — 120 KB/s" in await page.text_content("#status-panel")
# tap again closes; so does a click away
await _js_click(page, "#pill")
assert not await page.locator("#status-panel").count()
await _js_click(page, "#pill")
await page.wait_for_selector("#status-panel")
await _js_click(page, "body")
assert not await page.locator("#status-panel").count()
finally:
await browser.close()
def test_pill_degraded():
asyncio.run(_pill_degraded())
async def _pill_degraded():
async with async_playwright() as pw:
browser, page = await _open(pw, _cdn("DEGRADED", 23))
try:
await page.wait_for_function(
"document.querySelector('#pill .c').className.includes('degraded')",
timeout=5000, polling=POLL)
assert await page.text_content("#pill span") == "cdn 23 KB/s"
await _js_click(page, "#pill")
await page.wait_for_selector("#status-panel")
assert "CDN: DEGRADED — 23 KB/s" in await page.text_content("#status-panel")
finally:
await browser.close()
def test_pill_origin_down():
asyncio.run(_pill_origin_down())
async def _pill_origin_down():
async with async_playwright() as pw:
browser, page = await _open(pw, _cdn("OK", 120, origin="DOWN"))
try:
await page.wait_for_function(
"document.querySelector('#pill .o').className.includes('down')",
timeout=5000, polling=POLL)
assert await page.text_content("#pill span") == "upstream down"
cls = await page.get_attribute("#pill", "class")
assert "odown" in cls
finally:
await browser.close()