status: consolidate the pill to one dot + one word; upgrade the panel

- pill: two host dots -> ONE consolidated state (down > slow > up) —
  the at-a-glance question is 'can I watch?', which is one state;
  per-host detail belongs in the tap panel
- panel: dashed slow-threshold line in the CDN sparkline (drawn at the
  server's real threshold, not a client copy) + origin up/down step
  sparkline so 'how long has the origin been down' is visible
- server: origin probe keeps a 20-sample hist like the CDN probe;
  DEGRADED threshold is now ANIWEB_CDN_KBPS (default 40) and is
  returned in /api/cdn so the UI draws the line at the live value
- tests: pill assertions switched to consolidated states; fixtures
  carry origin hist + threshold; assert both sparklines + threshold line
- docs: README + SPEC synced; preview on :8322 restarted (pid 44382)
This commit is contained in:
jozamudi committed 2026-09-21 00:10:10 +00:00
1 parent 778bf2bfc1
commit 5f7aa010f1
8 files changed
+105 -56

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ server.log
server.pid
pi-session-*.html
.pytest_cache/
server-*.log
+5 -3
View File
@@ -61,9 +61,11 @@ 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.
- **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
+5 -3
View File
@@ -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
+8 -3
View File
@@ -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
+55 -30
View File
@@ -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;
}
+1 -1
View File
@@ -18,7 +18,7 @@
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
<input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
</form>
<span id="pill" class="pill"><i class="o"></i><i class="c"></i><span></span></span>
<span id="pill" class="pill"><i class="d"></i><span></span></span>
<span id="me" class="muted"></span>
<div id="menubox" style="display:none">
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
+7 -5
View File
@@ -176,12 +176,14 @@ header .pill { display: flex; align-items: center; gap: .4rem; flex: none;
.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.bad { color: #ef4444; } /* origin currently down */
.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; }
header .pill .o.down { background: #ef4444; }
header .pill.odown { border-color: #7f1d1d; }
.sp-thr { stroke: #f59e0b; stroke-width: 1; stroke-dasharray: 3 3; }
header .pill .d { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
header .pill .d.ok { background: #22c55e; }
header .pill .d.slow { background: #f59e0b; }
header .pill .d.down { background: #ef4444; }
header .pill.down { border-color: #7f1d1d; }
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
+23 -11
View File
@@ -4,6 +4,9 @@
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.
Pill states are CONSOLIDATED (one dot + one word): down (origin dead)
> slow (CDN degraded) > up. Per-host detail lives in the tap panel.
"""
import asyncio
import json
@@ -18,10 +21,14 @@ POLL = 100
def _cdn(state, kbps, origin="OK"):
# origin hist: all OK except the last 3 when origin is DOWN
ohist = [{"t": 1750000000 - 180 * (10 - i),
"state": "DOWN" if (origin == "DOWN" and i >= 8) else "OK"}
for i in range(11)]
return {
"state": state, "kbps": kbps, "ts": 1750000000,
"state": state, "kbps": kbps, "ts": 1750000000, "threshold": 40,
"hist": [{"t": 1750000000 - 180 * (10 - i), "kbps": 20 + i * 10} for i in range(11)],
"origin": {"state": origin, "msg": "http 200", "ts": 1750000000},
"origin": {"state": origin, "msg": "http 200", "ts": 1750000000, "hist": ohist},
}
@@ -57,13 +64,15 @@ async def _pill_ok():
browser, page = await _open(pw, _cdn("OK", 120))
try:
await page.wait_for_function(
"document.querySelector('#pill .c').className.includes('ok')",
"document.querySelector('#pill .d').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
# tap opens the panel: two sparklines (CDN + origin), the CDN
# one carries the dashed threshold line and one point per sample
await _js_click(page, "#pill")
await page.wait_for_selector("#status-panel .sp-svg polyline")
assert await page.locator("#status-panel polyline").count() == 2
assert await page.locator("#status-panel .sp-thr").count() == 1
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")
@@ -87,9 +96,9 @@ async def _pill_degraded():
browser, page = await _open(pw, _cdn("DEGRADED", 23))
try:
await page.wait_for_function(
"document.querySelector('#pill .c').className.includes('degraded')",
"document.querySelector('#pill .d').className.includes('slow')",
timeout=5000, polling=POLL)
assert await page.text_content("#pill span") == "cdn 23 KB/s"
assert await page.text_content("#pill span") == "slow · 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")
@@ -105,11 +114,14 @@ async def _pill_origin_down():
async with async_playwright() as pw:
browser, page = await _open(pw, _cdn("OK", 120, origin="DOWN"))
try:
# down beats a healthy CDN: one consolidated state
await page.wait_for_function(
"document.querySelector('#pill .o').className.includes('down')",
"document.querySelector('#pill .d').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
assert await page.text_content("#pill span") == "down"
assert "down" in await page.get_attribute("#pill", "class")
await _js_click(page, "#pill")
await page.wait_for_selector("#status-panel .sp-svg.bad")
assert "upstream: DOWN" in await page.text_content("#status-panel")
finally:
await browser.close()