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
This commit is contained in:
1 parent
63a677f028
commit
778bf2bfc1
5 files changed
+235
-37
No files matched your search
+103
-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,103 @@ 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) {
|
||||
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 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;
|
||||
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");
|
||||
}
|
||||
|
||||
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 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 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.
|
||||
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 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("role", "img");
|
||||
svg.setAttribute("aria-label", "CDN throughput trend, last hour");
|
||||
const pl = document.createElementNS(NS, "polyline");
|
||||
pl.setAttribute("points", pts);
|
||||
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() {
|
||||
|
||||
+10
-1
@@ -163,11 +163,20 @@ 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; }
|
||||
.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 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; }
|
||||
|
||||
Reference in new issue
Block a user