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:
1 parent
63a677f028
commit
778bf2bfc1
5 files changed
+225
-27
No files matched your search
@@ -61,6 +61,10 @@ proxy is the way in. See [SPEC.md](SPEC.md) for the rest.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
- **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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+93
-26
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user