Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7aa010f1 | ||
|
|
778bf2bfc1 |
No files matched your search
@@ -7,3 +7,4 @@ server.log
|
|||||||
server.pid
|
server.pid
|
||||||
pi-session-*.html
|
pi-session-*.html
|
||||||
.pytest_cache/
|
.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
|
- **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,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
@@ -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
|
||||||
|
|||||||
+127
-35
@@ -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
@@ -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
@@ -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); } }
|
||||||
|
|||||||
@@ -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