Compare commits
2
Commits
63a677f028
...
5f7aa010f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7aa010f1 | ||
|
|
778bf2bfc1 |
No files matched your search
@@ -7,3 +7,4 @@ server.log
|
||||
server.pid
|
||||
pi-session-*.html
|
||||
.pytest_cache/
|
||||
server-*.log
|
||||
@@ -61,6 +61,12 @@ proxy is the way in. See [SPEC.md](SPEC.md) for the rest.
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
(30–365 day windows).
|
||||
|
||||
@@ -82,6 +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.
|
||||
`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
@@ -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
|
||||
|
||||
+128
-36
@@ -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,128 @@ 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) {
|
||||
// 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;
|
||||
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 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 =
|
||||
!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() {
|
||||
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) ----------
|
||||
let nextTimer = null;
|
||||
function onEnded() {
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+17
-6
@@ -163,16 +163,27 @@ 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; }
|
||||
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; }
|
||||
.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.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%);
|
||||
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user