From 778bf2bfc1f71281e0fece8e7fe595431cb0cf93 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Sun, 20 Sep 2026 23:53:47 +0000 Subject: [PATCH] status: consolidate CDN/upstream status into the header pill + tap panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 4 ++ SPEC.md | 3 + static/app.js | 139 ++++++++++++++++++++++++++++---------- static/style.css | 11 ++- tests/test_status_pill.py | 115 +++++++++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 37 deletions(-) create mode 100644 tests/test_status_pill.py diff --git a/README.md b/README.md index 219e643..91d993c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ proxy is the way in. See [SPEC.md](SPEC.md) for the rest. ![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 split, weekday/per-day heatmaps, top-10 shows, top searches (30–365 day windows). diff --git a/SPEC.md b/SPEC.md index 1d5f886..74c011a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -82,6 +82,9 @@ an exception falls through to the next provider, a `None` from position-event stream. - `GET /api/cdn` → CDN + origin health (background 64KB probe every 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 upstream URL (host-allowlisted against the providers' self-registered CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8 diff --git a/static/app.js b/static/app.js index 31f214e..c316851 100644 --- a/static/app.js +++ b/static/app.js @@ -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() { diff --git a/static/style.css b/static/style.css index 5b0d8da..8560633 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/test_status_pill.py b/tests/test_status_pill.py new file mode 100644 index 0000000..5beaa13 --- /dev/null +++ b/tests/test_status_pill.py @@ -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()