diff --git a/.gitignore b/.gitignore index b7d8fc2..bf1ff1c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ server.log server.pid pi-session-*.html .pytest_cache/ +server-*.log diff --git a/README.md b/README.md index 91d993c..9b3ea08 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,11 @@ 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. +- **Status** — the header pill shows one consolidated state — up / + slow (KB/s) / down — as a single dot; tap opens a panel with the + per-host breakdown, the ~1h CDN trend (dashed slow-threshold line), + and origin up/down history. 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 diff --git a/SPEC.md b/SPEC.md index 74c011a..6537ce8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -82,9 +82,11 @@ 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. + `DEGRADED` = below `ANIWEB_CDN_KBPS` (default 40). The header pill is + the single UI surface: one consolidated state (down > slow > up, one + dot), tap opens the panel (per-host state + KB/s, CDN sparkline with + the dashed threshold line, origin up/down sparkline of `origin.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/server/app.py b/server/app.py index efd10e9..f2b04bb 100644 --- a/server/app.py +++ b/server/app.py @@ -48,7 +48,10 @@ VERSION = _version() _CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []} # origin (primary provider pages/API) health — separate host, separate failure # mode (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance") -_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0} +_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0, "hist": []} +# ponytail: fixed threshold; if the CDN's normal throughput drifts below it +# the pill sits on "slow" — tune via ANIWEB_CDN_KBPS, not a percentile. +_CDN_THRESHOLD = float(os.environ.get("ANIWEB_CDN_KBPS", "40")) def _cdn_probe(): @@ -81,7 +84,7 @@ def _cdn_probe(): kbps = len(r.content) / 1024 / max(time.time() - t, 0.01) except Exception: kbps = 0.0 - now = "OK" if kbps >= 40 else "DEGRADED" + now = "OK" if kbps >= _CDN_THRESHOLD else "DEGRADED" if now != state: print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True) state = now @@ -103,6 +106,8 @@ def _origin_probe(): state, msg = "DOWN", str(e) prev = _ORIGIN["state"] _ORIGIN.update(state=state, msg=msg, ts=time.time()) + _ORIGIN["hist"].append({"t": time.time(), "state": state}) + del _ORIGIN["hist"][:-20] if state != prev: print(f"[probe] ORIGIN {state} ({msg})", flush=True) @@ -114,7 +119,7 @@ threading.Thread(target=_cdn_probe, daemon=True).start() async def cdn_status(): """CDN + origin health (read-only, public). Top-level fields are the CDN probe (existing shape); .origin is the anidb.app pages/API probe.""" - return {**_CDN, "origin": _ORIGIN} + return {**_CDN, "threshold": _CDN_THRESHOLD, "origin": _ORIGIN} # ---------- auth: multi-user, signed cookies ---------- users.migrate_legacy() # file-based legacy import; db.init() absorbs its output diff --git a/static/app.js b/static/app.js index c316851..2da902c 100644 --- a/static/app.js +++ b/static/app.js @@ -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; } diff --git a/static/index.html b/static/index.html index fb2ce8b..33b4280 100644 --- a/static/index.html +++ b/static/index.html @@ -18,7 +18,7 @@
- +