Compare commits

..
2 Commits
Author SHA1 Message Date
jozamudi 5f7aa010f1 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)
2026-09-21 00:10:10 +00:00
jozamudi 778bf2bfc1 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
2026-09-20 23:53:47 +00:00
8 changed files with 291 additions and 44 deletions

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ server.log
server.pid server.pid
pi-session-*.html pi-session-*.html
.pytest_cache/ .pytest_cache/
server-*.log
+6
View File
@@ -61,6 +61,12 @@ 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 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 - **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).
+5
View File
@@ -82,6 +82,11 @@ 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.
`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 - `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
+8 -3
View File
@@ -48,7 +48,10 @@ VERSION = _version()
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []} _CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
# origin (primary provider pages/API) health — separate host, separate failure # origin (primary provider pages/API) health — separate host, separate failure
# mode (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance") # 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(): def _cdn_probe():
@@ -81,7 +84,7 @@ def _cdn_probe():
kbps = len(r.content) / 1024 / max(time.time() - t, 0.01) kbps = len(r.content) / 1024 / max(time.time() - t, 0.01)
except Exception: except Exception:
kbps = 0.0 kbps = 0.0
now = "OK" if kbps >= 40 else "DEGRADED" now = "OK" if kbps >= _CDN_THRESHOLD else "DEGRADED"
if now != state: if now != state:
print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True) print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True)
state = now state = now
@@ -103,6 +106,8 @@ def _origin_probe():
state, msg = "DOWN", str(e) state, msg = "DOWN", str(e)
prev = _ORIGIN["state"] prev = _ORIGIN["state"]
_ORIGIN.update(state=state, msg=msg, ts=time.time()) _ORIGIN.update(state=state, msg=msg, ts=time.time())
_ORIGIN["hist"].append({"t": time.time(), "state": state})
del _ORIGIN["hist"][:-20]
if state != prev: if state != prev:
print(f"[probe] ORIGIN {state} ({msg})", flush=True) print(f"[probe] ORIGIN {state} ({msg})", flush=True)
@@ -114,7 +119,7 @@ threading.Thread(target=_cdn_probe, daemon=True).start()
async def cdn_status(): async def cdn_status():
"""CDN + origin health (read-only, public). Top-level fields are the CDN """CDN + origin health (read-only, public). Top-level fields are the CDN
probe (existing shape); .origin is the anidb.app pages/API probe.""" 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 ---------- # ---------- auth: multi-user, signed cookies ----------
users.migrate_legacy() # file-based legacy import; db.init() absorbs its output users.migrate_legacy() # file-based legacy import; db.init() absorbs its output
+126 -34
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,128 @@ 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) {
// 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"); const p = document.getElementById("pill");
if (!p) return; if (!p) return;
api("/api/cdn").then(d => { const known = (d.origin.state === "OK" || d.origin.state === "DOWN")
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : ""; && (d.state === "OK" || d.state === "DEGRADED");
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : ""; const down = d.origin.state === "DOWN";
p.className = "pill" + (o === "down" ? " odown" : ""); const slow = !down && d.state === "DEGRADED";
const od = p.querySelector(".o"), cd = p.querySelector(".c"); const st = !known ? "" : down ? "down" : slow ? "slow" : "ok";
od.className = "o " + o; p.className = "pill" + (st === "down" ? " down" : "");
cd.className = "c " + c; p.querySelector(".d").className = "d" + (st ? " " + st : "");
p.querySelector("span").textContent = p.querySelector("span").textContent =
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up"; !st ? "…" : st === "down" ? "down"
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers) : st === "slow" ? "slow · " + d.kbps + " KB/s" : "up";
od.title = "upstream site (anidb.app pages + API): " + d.origin.state p.title = "upstream site: " + d.origin.state + (d.origin.msg ? " — " + d.origin.msg : "")
+ (d.origin.msg ? " — " + d.origin.msg : ""); + " · video CDN: " + d.state + " — " + d.kbps + " KB/s";
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state p.setAttribute("aria-label", "status: " + (!st ? "checking"
+ " — " + d.kbps + " KB/s"; : st === "down" ? "down — upstream " + d.origin.state
p.title = ""; // dot-level titles carry the detail; pill-level would shadow them : st === "slow" ? "slow — CDN " + d.state + ", " + d.kbps + " KB/s"
p.setAttribute("aria-label", "upstream site " + d.origin.state : "up"));
+ (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 = "?"; }); 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 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 _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(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" + (binary && !healthy ? " bad" : ""));
svg.setAttribute("role", "img");
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.join(" "));
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() {
+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"> <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"> <input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
</form> </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> <span id="me" class="muted"></span>
<div id="menubox" style="display:none"> <div id="menubox" style="display:none">
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button> <button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
+17 -6
View File
@@ -163,16 +163,27 @@ 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; }
header .pill i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; } .status-panel { position: absolute; top: 100%; right: 0; z-index: 6; margin-top: .35rem;
header .pill .o.ok, header .pill .c.ok { background: #22c55e; } padding: .5rem .65rem; border-radius: 8px; border: 1px solid var(--bd);
header .pill .c.degraded { background: #f59e0b; } background: var(--card); color: var(--fg); font-size: .78rem; min-width: 170px;
header .pill .o.down { background: #ef4444; } display: flex; flex-direction: column; gap: .3rem; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
header .pill.odown { border-color: #7f1d1d; } .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; }
.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%); #toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: .4rem; z-index: 9; } display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } } @keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
+127
View File
@@ -0,0 +1,127 @@
"""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.
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
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"):
# 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, "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, "hist": ohist},
}
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 .d').className.includes('ok')",
timeout=5000, polling=POLL)
assert await page.text_content("#pill span") == "up"
# 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")
# 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 .d').className.includes('slow')",
timeout=5000, polling=POLL)
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")
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:
# down beats a healthy CDN: one consolidated state
await page.wait_for_function(
"document.querySelector('#pill .d').className.includes('down')",
timeout=5000, polling=POLL)
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()