LB buckets column + health cascade; VPP dump fix; maglevc strictness
SPA (cmd/frontend/web): - New "lb buckets" column backed by a 1s-debounced GetVPPLBState fetch loop with leading+trailing edge coalesce. - Per-frontend health icon (✅/⚠️/❗/‼️/❓) in the Zippy header, gated by a settling flag that suppresses ‼️ until the next lb-state reconciliation after a backend transition or weight change. - In-place leaf merge on lb-state so stable bucket values (e.g. "0") don't retrigger the Flash animation on every refresh. - Zippy cards remember open state in a cookie, default closed on fresh load; fixed-width frontend-title-name + reserved icon slot so headers line up across all cards. - Clock-drift watchdog in sse.ts that forces a fresh EventSource on laptop-wake so the broker emits a resync instead of hanging on a dead half-open socket. Frontend service (cmd/frontend): - maglevClient.lbStateLoop, trigger on backend transitions + vpp-connect, best-effort fetch on refreshAll. - Admin handlers explicitly wake the lb-state loop after lifecycle ops and set-weight (the latter emits no transition event on the maglevd side, so the WatchEvents path wouldn't have caught it). - /favicon.ico served from embedded web/public IPng logo. VPP integration: - internal/vpp/lbstate.go: dumpASesForVIP drops Pfx from the dump request (setting it silently wipes IPv4 replies in the LB plugin) and filters results by prefix on the response side instead, which also demuxes multi-VIP-on-same-port cases correctly. maglevc: - Walk now returns the unconsumed token tail; dispatch and the question listener reject unknown commands with a targeted error instead of dumping the full command tree prefixed with garbage. - On '?', echo the current line (including the '?') before the help list so the output reads like birdc. Checker / prober: - internal/checker: ±10% jitter on NextInterval so probes across restart don't all fire on the same tick. - internal/prober: HTTP User-Agent now carries the build version and project URL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
cmd/frontend/web/dist/assets/index-3BvNJ7QB.css
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-3BvNJ7QB.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/frontend/web/dist/assets/index-DCJJqBMY.js
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-DCJJqBMY.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
cmd/frontend/web/dist/favicon.ico
vendored
Normal file
BIN
cmd/frontend/web/dist/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
5
cmd/frontend/web/dist/index.html
vendored
5
cmd/frontend/web/dist/index.html
vendored
@@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/view/favicon.ico" />
|
||||
<title>maglev</title>
|
||||
<script type="module" crossorigin src="/view/assets/index-DjixLt11.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/view/assets/index-CExoCDXh.css">
|
||||
<script type="module" crossorigin src="/view/assets/index-DCJJqBMY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/view/assets/index-3BvNJ7QB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>maglev</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
BIN
cmd/frontend/web/public/favicon.ico
Normal file
BIN
cmd/frontend/web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -2,6 +2,7 @@ import type {
|
||||
BackendEventPayload,
|
||||
BrowserEvent,
|
||||
FrontendEventPayload,
|
||||
LBStatePayload,
|
||||
MaglevdStatusPayload,
|
||||
VPPStatusPayload,
|
||||
} from "../types";
|
||||
@@ -9,6 +10,7 @@ import { fetchAllState } from "./rest";
|
||||
import {
|
||||
applyBackendTransition,
|
||||
applyFrontendTransition,
|
||||
applyLBState,
|
||||
applyMaglevdStatus,
|
||||
applyVPPStatus,
|
||||
replaceAll,
|
||||
@@ -19,10 +21,24 @@ import { pushEvent } from "../stores/events";
|
||||
// reconnects with the Last-Event-ID header set, which the Go broker uses
|
||||
// to replay events from its 30s ring buffer. A "resync" event tells us to
|
||||
// refetch full state and redraw.
|
||||
export function openEventStream(): EventSource {
|
||||
const es = new EventSource("/view/api/events");
|
||||
//
|
||||
// On top of the browser's built-in reconnect we also run a clock-drift
|
||||
// watchdog: a setInterval that doesn't fire during OS suspend, so a tick
|
||||
// that arrives much later than expected almost always means the laptop
|
||||
// just came back from sleep. EventSource doesn't notice when its TCP
|
||||
// connection has been silently torn down during sleep (no FIN was
|
||||
// delivered, so readyState stays OPEN forever), so we force-reconnect
|
||||
// ourselves on a wake. The new connection sends no Last-Event-ID, which
|
||||
// makes the broker emit a "resync" event and the handler below refetches
|
||||
// full state.
|
||||
const SSE_WAKE_TICK_MS = 10_000;
|
||||
const SSE_WAKE_THRESHOLD_MS = 30_000;
|
||||
|
||||
es.onmessage = (msg) => {
|
||||
export function openEventStream(): void {
|
||||
let es: EventSource | undefined;
|
||||
let reconnecting = false;
|
||||
|
||||
const onMessage = (msg: MessageEvent) => {
|
||||
try {
|
||||
const ev = JSON.parse(msg.data) as BrowserEvent;
|
||||
dispatch(ev);
|
||||
@@ -31,23 +47,60 @@ export function openEventStream(): EventSource {
|
||||
}
|
||||
};
|
||||
|
||||
// "resync" is emitted as a named event so we can listen for it
|
||||
// without it going through the default onmessage dispatch.
|
||||
es.addEventListener("resync", async () => {
|
||||
const onResync = async () => {
|
||||
try {
|
||||
const snaps = await fetchAllState();
|
||||
replaceAll(snaps);
|
||||
} catch (err) {
|
||||
console.error("resync refetch failed", err);
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = (err) => {
|
||||
// EventSource handles reconnection on its own — just log.
|
||||
console.debug("sse error, browser will reconnect", err);
|
||||
};
|
||||
|
||||
return es;
|
||||
const connect = () => {
|
||||
if (es) {
|
||||
es.close();
|
||||
es = undefined;
|
||||
}
|
||||
es = new EventSource("/view/api/events");
|
||||
es.onmessage = onMessage;
|
||||
es.addEventListener("resync", onResync);
|
||||
es.onerror = (err) => {
|
||||
// EventSource handles reconnection on its own — just log.
|
||||
console.debug("sse error, browser will reconnect", err);
|
||||
};
|
||||
};
|
||||
|
||||
const reconnect = (reason: string) => {
|
||||
// Coalesce multiple wake signals that fire within the same instant
|
||||
// (e.g. clock-drift tick AND a future visibility hook). One brief
|
||||
// window is enough; subsequent calls are no-ops.
|
||||
if (reconnecting) return;
|
||||
reconnecting = true;
|
||||
console.info("sse reconnecting:", reason);
|
||||
connect();
|
||||
setTimeout(() => {
|
||||
reconnecting = false;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Wake detector. The interval is short enough (10s) to catch even
|
||||
// brief naps, the threshold (30s) is well above the interval + JS
|
||||
// jitter so a clean wake reads unambiguously, and we never trigger
|
||||
// on normal background-tab throttling because that doesn't usually
|
||||
// pause setInterval for 30+ seconds at a time. If a future Chrome
|
||||
// policy starts throttling that aggressively, the worst case is one
|
||||
// extra reconnect every few minutes — still cheap.
|
||||
let lastTick = Date.now();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastTick;
|
||||
lastTick = now;
|
||||
if (elapsed > SSE_WAKE_THRESHOLD_MS) {
|
||||
reconnect(`wake detected (${Math.round(elapsed / 1000)}s gap)`);
|
||||
}
|
||||
}, SSE_WAKE_TICK_MS);
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
function dispatch(ev: BrowserEvent) {
|
||||
@@ -68,6 +121,9 @@ function dispatch(ev: BrowserEvent) {
|
||||
case "vpp-status":
|
||||
applyVPPStatus(ev.maglevd, (ev.payload as VPPStatusPayload).state);
|
||||
break;
|
||||
case "lb-state":
|
||||
applyLBState(ev.maglevd, ev.payload as LBStatePayload);
|
||||
break;
|
||||
case "log":
|
||||
// Log events are displayed in the DebugPanel but no longer
|
||||
// mutate the state tree. The previous vpp-lb-sync-as-*
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import type { Component, JSX } from "solid-js";
|
||||
import { isZippyOpen, setZippyOpen } from "../stores/zippy";
|
||||
|
||||
type Props = {
|
||||
// Stable identifier used as the cookie key. Must be unique within
|
||||
// the app; a fresh page load opens the Zippy iff its id is present
|
||||
// in the persisted open-set, so changing the id "forgets" prior
|
||||
// user state for that panel.
|
||||
id: string;
|
||||
title: JSX.Element;
|
||||
open?: boolean;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
const Zippy: Component<Props> = (props) => {
|
||||
return (
|
||||
<details class="zippy" open={props.open}>
|
||||
<details
|
||||
class="zippy"
|
||||
open={isZippyOpen(props.id)}
|
||||
onToggle={(e) => setZippyOpen(props.id, e.currentTarget.open)}
|
||||
>
|
||||
<summary>{props.title}</summary>
|
||||
<div class="zippy-body">{props.children}</div>
|
||||
</details>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createStore, produce } from "solid-js/store";
|
||||
import type {
|
||||
BackendEventPayload,
|
||||
FrontendEventPayload,
|
||||
FrontendSnapshot,
|
||||
LBStatePayload,
|
||||
MaglevdStatusPayload,
|
||||
StateSnapshot,
|
||||
TransitionRecord,
|
||||
@@ -83,14 +85,81 @@ function recomputeDerivedState(snap: StateSnapshot) {
|
||||
|
||||
// FrontendState keys snapshots by maglevd name. A single store drives the
|
||||
// whole UI; reducers produce() into the right branch.
|
||||
//
|
||||
// settling is a per-(maglevd, frontend) flag flipped to true on any
|
||||
// event that changes which backends should be serving — backend
|
||||
// transitions, configured weight edits — and auto-cleared after a
|
||||
// fixed grace window. While true, frontendHealth suppresses the
|
||||
// bug-buckets verdict so a transient race between the new control-
|
||||
// plane state and the lagging GetVPPLBState refetch doesn't flash
|
||||
// the ‼️ icon. A real, persistent dataplane disagreement still shows
|
||||
// up the moment the grace window expires.
|
||||
export type FrontendState = {
|
||||
byName: Record<string, StateSnapshot>;
|
||||
settling: Record<string, Record<string, true>>;
|
||||
};
|
||||
|
||||
const [state, setState] = createStore<FrontendState>({ byName: {} });
|
||||
const [state, setState] = createStore<FrontendState>({ byName: {}, settling: {} });
|
||||
|
||||
export { state };
|
||||
|
||||
const SETTLE_GRACE_MS = 2000;
|
||||
|
||||
// Outside-the-store map of pending auto-clear timers, keyed by
|
||||
// (maglevd, frontend). Timer ids aren't UI state so they don't
|
||||
// belong in the reactive store; keeping them in a plain Map lets a
|
||||
// fresh transition cancel and restart the timer cleanly.
|
||||
const settlingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
function settleKey(m: string, f: string): string {
|
||||
return `${m}\x00${f}`;
|
||||
}
|
||||
|
||||
function markFrontendSettling(maglevd: string, frontend: string) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
if (!s.settling[maglevd]) s.settling[maglevd] = {};
|
||||
s.settling[maglevd][frontend] = true;
|
||||
}),
|
||||
);
|
||||
const k = settleKey(maglevd, frontend);
|
||||
const existing = settlingTimers.get(k);
|
||||
if (existing) clearTimeout(existing);
|
||||
settlingTimers.set(
|
||||
k,
|
||||
setTimeout(() => {
|
||||
settlingTimers.delete(k);
|
||||
setState(
|
||||
produce((s) => {
|
||||
if (s.settling[maglevd]) delete s.settling[maglevd][frontend];
|
||||
}),
|
||||
);
|
||||
}, SETTLE_GRACE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
// clearMaglevdSettling is called from applyLBState the moment a fresh
|
||||
// GetVPPLBState reconciliation lands. The dataplane data is now at
|
||||
// least as new as whatever transitions triggered the wait, so any
|
||||
// remaining bug-buckets discrepancy is real and worth surfacing.
|
||||
// The 2s safety timer in markFrontendSettling exists only as a
|
||||
// fallback for the case where VPP is disconnected (or the fetch is
|
||||
// failing) and an lb-state event would never arrive — without the
|
||||
// timer, settling would get stuck and the icon would silently
|
||||
// suppress real bugs.
|
||||
function clearMaglevdSettling(maglevd: string) {
|
||||
for (const [k, id] of settlingTimers) {
|
||||
if (k.startsWith(maglevd + "\x00")) {
|
||||
clearTimeout(id);
|
||||
settlingTimers.delete(k);
|
||||
}
|
||||
}
|
||||
setState(
|
||||
produce((s) => {
|
||||
if (s.settling[maglevd]) s.settling[maglevd] = {};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceSnapshot(snap: StateSnapshot) {
|
||||
// Recompute effective weights + aggregate frontend state locally
|
||||
// from the snapshot's backends array, rather than trusting the
|
||||
@@ -146,6 +215,17 @@ export function applyBackendTransition(maglevd: string, p: BackendEventPayload)
|
||||
recomputeDerivedState(snap);
|
||||
}),
|
||||
);
|
||||
// Mark every frontend that references this backend as settling so
|
||||
// the bug-buckets verdict is gated on the next fresh GetVPPLBState
|
||||
// reconciliation (or the 2s safety timer, whichever fires first).
|
||||
const snap = state.byName[maglevd];
|
||||
if (snap) {
|
||||
for (const fe of snap.frontends) {
|
||||
if (fe.pools.some((pool) => pool.backends.some((pb) => pb.name === p.backend))) {
|
||||
markFrontendSettling(maglevd, fe.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frontend-transition events arrive from the server's checker, but
|
||||
@@ -160,6 +240,70 @@ export function applyFrontendTransition(_maglevd: string, _p: FrontendEventPaylo
|
||||
// no-op — state is derived client-side, see recomputeDerivedState
|
||||
}
|
||||
|
||||
// applyLBState merges the per-frontend bucket map for one maglevd
|
||||
// from a freshly-arrived "lb-state" SSE event. A null/undefined
|
||||
// per_frontend payload (sent on VPP disconnect or fetch failure)
|
||||
// clears the cached map so the SPA renders em-dashes in the buckets
|
||||
// column instead of stale numbers.
|
||||
//
|
||||
// The merge is done leaf-by-leaf rather than via wholesale assignment.
|
||||
// produce's proxy only emits a signal when a property is actually
|
||||
// written, so guarding each write with `!==` keeps unchanged numbers
|
||||
// (in particular every drained-to-0 backend) from invalidating their
|
||||
// downstream reactive reads. Without this, the periodic 30s refresh
|
||||
// and every same-value re-fetch would re-trigger the Flash animation
|
||||
// on every cell — which is exactly the visual storm we're avoiding.
|
||||
export function applyLBState(maglevd: string, p: LBStatePayload) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const snap = s.byName[maglevd];
|
||||
if (!snap) return;
|
||||
const next = p.per_frontend;
|
||||
const empty = !next || Object.keys(next).length === 0;
|
||||
if (empty) {
|
||||
if (snap.lb_state !== undefined) snap.lb_state = undefined;
|
||||
return;
|
||||
}
|
||||
if (!snap.lb_state) {
|
||||
snap.lb_state = { per_frontend: {} };
|
||||
}
|
||||
const cur = snap.lb_state.per_frontend;
|
||||
// Update / insert leaves that actually changed.
|
||||
for (const fe of Object.keys(next)) {
|
||||
if (!cur[fe]) cur[fe] = {};
|
||||
const curRow = cur[fe];
|
||||
const nextRow = next[fe];
|
||||
for (const be of Object.keys(nextRow)) {
|
||||
if (curRow[be] !== nextRow[be]) curRow[be] = nextRow[be];
|
||||
}
|
||||
for (const be of Object.keys(curRow)) {
|
||||
if (!(be in nextRow)) delete curRow[be];
|
||||
}
|
||||
}
|
||||
// Drop frontends that disappeared from the new snapshot.
|
||||
for (const fe of Object.keys(cur)) {
|
||||
if (!(fe in next)) delete cur[fe];
|
||||
}
|
||||
}),
|
||||
);
|
||||
// A fresh lb-state event means the dataplane data is now at least
|
||||
// as new as anything we were waiting on — re-enable bug detection.
|
||||
clearMaglevdSettling(maglevd);
|
||||
}
|
||||
|
||||
// lbBucketsFor looks up the bucket count VPP currently routes to a
|
||||
// given backend on a given frontend. Returns undefined when the
|
||||
// snapshot has no LB state at all (VPP disconnected, no fetch yet) or
|
||||
// when the backend isn't programmed into VPP for that VIP — the view
|
||||
// renders an em-dash in both cases.
|
||||
export function lbBucketsFor(
|
||||
snap: StateSnapshot | undefined,
|
||||
frontend: string,
|
||||
backend: string,
|
||||
): number | undefined {
|
||||
return snap?.lb_state?.per_frontend?.[frontend]?.[backend];
|
||||
}
|
||||
|
||||
export function applyVPPStatus(maglevd: string, state: string) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
@@ -211,6 +355,89 @@ export function applyConfiguredWeight(
|
||||
recomputeDerivedState(snap);
|
||||
}),
|
||||
);
|
||||
markFrontendSettling(maglevd, frontend);
|
||||
}
|
||||
|
||||
// FrontendHealth is the per-frontend "is everything actually working"
|
||||
// verdict computed from backend states, effective weights, and (when
|
||||
// available) the VPP bucket map. The cascade is intentionally
|
||||
// priority-ordered: a data-plane disagreement (control says serve,
|
||||
// VPP routes nothing) is the loudest signal because it usually means
|
||||
// something is broken in the sync path, not just an unhealthy backend.
|
||||
//
|
||||
// "ok" → all backends up, primary serving, every
|
||||
// eff>0 backend has VPP buckets>0
|
||||
// "bug-buckets" → some backend with effective_weight>0 has 0
|
||||
// buckets in VPP — control plane and data
|
||||
// plane disagree, almost always a bug
|
||||
// "primary-drained" → primary pool is not serving any traffic
|
||||
// (every backend in pool[0] has eff=0); the
|
||||
// frontend is on its fallback or fully down
|
||||
// "degraded" → at least one backend isn't 'up' but nothing
|
||||
// worse — typical maintenance / outage state
|
||||
// "unknown" → fallthrough; should be unreachable, kept as
|
||||
// a safety net for logic bugs in this function
|
||||
export type FrontendHealth =
|
||||
| "ok"
|
||||
| "bug-buckets"
|
||||
| "primary-drained"
|
||||
| "degraded"
|
||||
| "unknown";
|
||||
|
||||
export function frontendHealth(snap: StateSnapshot, fe: FrontendSnapshot): FrontendHealth {
|
||||
const stateOf: Record<string, string> = {};
|
||||
for (const b of snap.backends) stateOf[b.name] = b.state;
|
||||
|
||||
// The bucket check is only meaningful when we actually have an LB
|
||||
// state snapshot. On a fresh page load (or with VPP disconnected)
|
||||
// lb_state is undefined; in that window we fall back to "trust the
|
||||
// control plane" so the icon still settles to ✅ instead of
|
||||
// perpetual ❓ until the first GetVPPLBState round-trip.
|
||||
const lbAvailable = !!snap.lb_state;
|
||||
const feBuckets = snap.lb_state?.per_frontend?.[fe.name];
|
||||
// Reactive read of the per-frontend settling flag. While true,
|
||||
// we're still waiting for the next GetVPPLBState reconciliation
|
||||
// after a recent control-plane change; the dataplane may be mid-
|
||||
// reconverge so any "weight>0 but buckets==0" we'd see here is
|
||||
// almost certainly a race, not a real bug.
|
||||
const settling = !!state.settling[snap.maglevd.name]?.[fe.name];
|
||||
|
||||
let anyDown = false;
|
||||
let dataplaneBug = false;
|
||||
for (const pool of fe.pools) {
|
||||
for (const pb of pool.backends) {
|
||||
if (stateOf[pb.name] !== "up") anyDown = true;
|
||||
if (!settling && lbAvailable && pb.effective_weight > 0) {
|
||||
const b = feBuckets?.[pb.name];
|
||||
if (b === undefined || b === 0) dataplaneBug = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const primary = fe.pools[0];
|
||||
const primaryHasWeights = !!primary && primary.backends.some((pb) => pb.weight > 0);
|
||||
const primaryAllZero = !primary || primary.backends.every((pb) => pb.effective_weight === 0);
|
||||
|
||||
if (!anyDown && primaryHasWeights && !dataplaneBug) return "ok";
|
||||
if (dataplaneBug) return "bug-buckets";
|
||||
if (primaryAllZero) return "primary-drained";
|
||||
if (anyDown) return "degraded";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function frontendHealthIcon(snap: StateSnapshot, fe: FrontendSnapshot): string {
|
||||
switch (frontendHealth(snap, fe)) {
|
||||
case "ok":
|
||||
return "✅";
|
||||
case "bug-buckets":
|
||||
return "‼️";
|
||||
case "primary-drained":
|
||||
return "❗";
|
||||
case "degraded":
|
||||
return "⚠️";
|
||||
case "unknown":
|
||||
return "❓";
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers used by views.
|
||||
|
||||
55
cmd/frontend/web/src/stores/zippy.ts
Normal file
55
cmd/frontend/web/src/stores/zippy.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
// Persistence layer for collapsible (Zippy) panels. The cookie is a
|
||||
// best-effort hint: the page always renders all Zippies closed unless
|
||||
// their stable id is in the cookie's open-set, but a missing or
|
||||
// corrupt cookie just falls back to "everything closed", so losing it
|
||||
// (browser data clear, expiry, private window, write failure) is a
|
||||
// pure cosmetic regression.
|
||||
//
|
||||
// localStorage would arguably be a tidier home for this — it's
|
||||
// client-only and doesn't ride on every HTTP request — but the
|
||||
// payload is tiny and the user asked for a cookie, so a cookie it
|
||||
// is. SameSite=Lax keeps it from leaking to third-party iframes.
|
||||
|
||||
const COOKIE_NAME = "maglev_zippy_open";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
function readCookie(): Set<string> {
|
||||
try {
|
||||
const raw = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(COOKIE_NAME + "="));
|
||||
if (!raw) return new Set();
|
||||
const value = decodeURIComponent(raw.slice(COOKIE_NAME.length + 1));
|
||||
if (!value) return new Set();
|
||||
return new Set(value.split(","));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeCookie(ids: Set<string>) {
|
||||
try {
|
||||
const value = encodeURIComponent([...ids].join(","));
|
||||
document.cookie = `${COOKIE_NAME}=${value}; Path=/; Max-Age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
} catch {
|
||||
// best-effort — quota, third-party-cookie blocks, etc. all silently fall back
|
||||
}
|
||||
}
|
||||
|
||||
const [openSet, setOpenSet] = createSignal<Set<string>>(readCookie());
|
||||
|
||||
export function isZippyOpen(id: string): boolean {
|
||||
return openSet().has(id);
|
||||
}
|
||||
|
||||
export function setZippyOpen(id: string, open: boolean) {
|
||||
const cur = openSet();
|
||||
if (open === cur.has(id)) return;
|
||||
const next = new Set(cur);
|
||||
if (open) next.add(id);
|
||||
else next.delete(id);
|
||||
setOpenSet(next);
|
||||
writeCookie(next);
|
||||
}
|
||||
@@ -174,9 +174,27 @@
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.frontend-title-icon {
|
||||
display: inline-block;
|
||||
width: 1.5em;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.frontend-title-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
/* Fixed-width slot so the state badge (and everything after it)
|
||||
* lines up across every Zippy header. 40ch is wide enough for the
|
||||
* longest realistic frontend name without crowding the icon to its
|
||||
* left. Names that exceed the slot get an ellipsis rather than
|
||||
* pushing the badge sideways. */
|
||||
display: inline-block;
|
||||
width: 40ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.frontend-title-addr {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
@@ -280,6 +298,9 @@
|
||||
.backend-table .col-effective {
|
||||
width: 95px;
|
||||
}
|
||||
.backend-table .col-buckets {
|
||||
width: 95px;
|
||||
}
|
||||
.backend-table .col-age {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
@@ -79,15 +79,29 @@ export type StateSnapshot = {
|
||||
healthchecks: HealthCheckSnapshot[];
|
||||
vpp_info?: VPPInfoSnapshot;
|
||||
vpp_state?: string; // "connected" | "disconnected" | ""
|
||||
lb_state?: LBStateSnapshot;
|
||||
};
|
||||
|
||||
// LBStateSnapshot is VPP's view of buckets-per-backend, scoped per
|
||||
// frontend. lb_state.per_frontend[frontendName][backendName] is the
|
||||
// number of consistent-hash buckets VPP currently routes to that
|
||||
// backend on that VIP. Missing entries (or a missing snapshot) render
|
||||
// as an em-dash in the SPA.
|
||||
export type LBStateSnapshot = {
|
||||
per_frontend: Record<string, Record<string, number>>;
|
||||
};
|
||||
|
||||
export type BrowserEvent = {
|
||||
maglevd: string;
|
||||
type: "log" | "backend" | "frontend" | "maglevd-status" | "vpp-status" | "resync";
|
||||
type: "log" | "backend" | "frontend" | "maglevd-status" | "vpp-status" | "lb-state" | "resync";
|
||||
at_unix_ns: number;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export type LBStatePayload = {
|
||||
per_frontend: Record<string, Record<string, number>> | null;
|
||||
};
|
||||
|
||||
export type BackendEventPayload = {
|
||||
backend: string;
|
||||
transition: TransitionRecord;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Show, type Component } from "solid-js";
|
||||
import { Show, createMemo, type Component } from "solid-js";
|
||||
import type { BackendSnapshot, PoolBackendSnapshot } from "../types";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
import ProbeHeartbeat from "../components/ProbeHeartbeat";
|
||||
import Flash from "../components/Flash";
|
||||
import BackendActionsMenu from "../components/BackendActionsMenu";
|
||||
import { lastTransitionAge } from "../stores/state";
|
||||
import { lastTransitionAge, lbBucketsFor, state as appState } from "../stores/state";
|
||||
import { isAdmin } from "../stores/mode";
|
||||
|
||||
type Props = {
|
||||
@@ -27,6 +27,18 @@ type Props = {
|
||||
|
||||
const BackendRow: Component<Props> = (props) => {
|
||||
const b = () => props.backend;
|
||||
// Subscribed lookup: lbBucketsFor reads from the reactive store, so
|
||||
// the cell re-renders the moment a "lb-state" SSE event mutates the
|
||||
// map. A missing entry (VPP disconnected, backend not yet programmed)
|
||||
// renders as an em-dash; an explicit 0 means "in VPP, drained".
|
||||
// createMemo guarantees Flash only sees a new value when the leaf
|
||||
// actually changed — without it, any spurious upstream re-run (e.g.
|
||||
// a sibling backend's transition triggering recomputeDerivedState)
|
||||
// would pop the bucket cell on every backend in the table.
|
||||
const bucketsLabel = createMemo<number | "—">(() => {
|
||||
const v = lbBucketsFor(appState.byName[props.maglevd], props.frontend, b().name);
|
||||
return v === undefined ? "—" : v;
|
||||
});
|
||||
return (
|
||||
<tr
|
||||
class="backend-row"
|
||||
@@ -50,6 +62,9 @@ const BackendRow: Component<Props> = (props) => {
|
||||
<td class="numeric">
|
||||
<Flash value={props.poolBackend.effective_weight} />
|
||||
</td>
|
||||
<td class="numeric">
|
||||
<Flash value={bucketsLabel()} />
|
||||
</td>
|
||||
<td class="age">{lastTransitionAge(b().last_transition)}</td>
|
||||
<Show when={isAdmin}>
|
||||
<td class="actions">
|
||||
|
||||
@@ -44,7 +44,7 @@ const DebugPanel: Component = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Zippy title="Event stream">
|
||||
<Zippy id="debug-events" title="Event stream">
|
||||
<ol class="event-tail" ref={olRef}>
|
||||
<For each={filtered()}>
|
||||
{(ev) => (
|
||||
|
||||
@@ -5,7 +5,7 @@ import StatusBadge from "../components/StatusBadge";
|
||||
import Flash from "../components/Flash";
|
||||
import Zippy from "../components/Zippy";
|
||||
import { isAdmin } from "../stores/mode";
|
||||
import { formatVIPAddress } from "../stores/state";
|
||||
import { formatVIPAddress, frontendHealthIcon } from "../stores/state";
|
||||
|
||||
type Props = {
|
||||
snap: StateSnapshot;
|
||||
@@ -26,8 +26,15 @@ const FrontendCard: Component<Props> = (props) => {
|
||||
const backendByName = () => Object.fromEntries(props.snap.backends.map((b) => [b.name, b]));
|
||||
const fe = () => props.frontend;
|
||||
|
||||
// The icon span has a fixed width so the rest of the title doesn't
|
||||
// jiggle horizontally when the verdict changes (✅ ↔ ⚠️ ↔ ❗ etc.).
|
||||
// The role/aria-label gives the meaning without depending on the
|
||||
// emoji glyph reading well to a screen reader.
|
||||
const title = (
|
||||
<span class="frontend-title">
|
||||
<span class="frontend-title-icon" aria-label="health" role="img">
|
||||
{frontendHealthIcon(props.snap, fe())}
|
||||
</span>
|
||||
<span class="frontend-title-name">{fe().name}</span>
|
||||
<Flash value={fe().state ?? "unknown"}>
|
||||
<StatusBadge state={fe().state ?? "unknown"} />
|
||||
@@ -40,7 +47,7 @@ const FrontendCard: Component<Props> = (props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Zippy title={title} open>
|
||||
<Zippy id={`frontend-${props.snap.maglevd.name}-${fe().name}`} title={title}>
|
||||
<table class="backend-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -50,6 +57,7 @@ const FrontendCard: Component<Props> = (props) => {
|
||||
<th class="col-state">state</th>
|
||||
<th class="col-weight numeric">weight</th>
|
||||
<th class="col-effective numeric">effective</th>
|
||||
<th class="col-buckets numeric">lb buckets</th>
|
||||
<th class="col-age">last transition</th>
|
||||
<Show when={isAdmin}>
|
||||
<th class="col-actions actions" />
|
||||
|
||||
@@ -77,7 +77,7 @@ const VPPInfoPanel: Component<Props> = (props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Zippy title={title}>
|
||||
<Zippy id={`vpp-${props.name}`} title={title}>
|
||||
<Show when={props.info} fallback={<p class="empty">No VPP information available.</p>}>
|
||||
{(i) => (
|
||||
<dl class="kv">
|
||||
|
||||
Reference in New Issue
Block a user