New maglev-frontend component; promote LB sync events to INFO

Introduces maglev-frontend, a responsive, real-time web dashboard for one
or more running maglevd instances. Source lives at cmd/frontend/; the
built binary is maglev-frontend. It is a single Go process with the
SolidJS SPA embedded via //go:embed — no runtime file dependencies.

Architecture
 - One persistent gRPC connection per configured maglevd (-server A,B,C).
   Each connection runs three background loops: a WatchEvents stream
   subscribed at log_level=debug for live events, a 30s refresh loop as
   a safety net for drift, and a 5s health loop that surfaces connection
   drops quickly.
 - In-process pub/sub broker with a 30s / 2000-event replay ring using
   <epoch>-<seq> monotonic IDs. Short browser reconnects (nginx idle,
   wifi flap, laptop wake) silently replay buffered events via the
   EventSource Last-Event-ID header; longer outages or frontend restarts
   fall through to a "resync" event that triggers a full state refetch.
 - HTTP surface: /view/ (SPA), /view/api/state, /view/api/state/{name},
   /view/api/maglevds, /view/api/version, /view/api/events (SSE),
   /healthz, and an /admin/* placeholder returning 501 for a future
   basic-auth mutation surface.
 - SSE handler follows the full operational checklist: retry hint, 15s
   : ping heartbeat, Flush after every write, r.Context().Done() teardown,
   X-Accel-Buffering: no, and no gzip.

SolidJS SPA (cmd/frontend/web/, Vite + TypeScript)
 - solid-js/store for a reactive per-maglevd state tree; reducers apply
   backend transitions, maglevd-status flips, and resync refetches.
 - Scope selector tabs for multi-maglevd support, per-maglevd frontend
   cards with pool tables showing state, configured weight, effective
   weight, and last-transition age.
 - ProbeHeartbeat component turns a middle-dot into ❤️ on probe-start and
   back on probe-done, driven by real log events; fixed-size wrapper so
   the emoji swap doesn't jiggle the row.
 - Flash wrapper animates any primitive on change (1s yellow fade via
   Web Animations API, skipped on first mount). Wired into the state
   badge, configured weight, and effective weight columns.
 - DebugPanel: chronological rolling event tail with tail-style auto-
   scroll, pause/resume, and scope/firehose filter. Syntactic highlight
   for vpp-lb-sync-* events with fixed-order attribute formatting.
 - Live effective_weight updates: vpp-lb-sync-as-added/removed/weight-
   updated log events are routed through a reducer that walks the
   snapshot's pool rows and sets effective_weight on every match
   without waiting for the 30s refresh.
 - Header shows build version + commit with build date in a tooltip,
   fetched once from /view/api/version on mount.
 - Prettier wired in as the web-side fixstyle; make fixstyle now tidies
   both Go and web in one shot via a new fixstyle-web target.

Per-mutation VPP LB sync logging
 - Promotes the addVIP/delVIP/addAS/delAS/setASWeight helpers from
   slog.Debug to slog.Info and renames them from vpp-lbsync-* to
   vpp-lb-sync-{vip-added,vip-removed,as-added,as-removed,as-weight-
   updated}. Matching rename for vpp-lb-sync-start / -done / -error /
   -vip-recreate. The Prometheus metric name (maglev_vpp_lbsync_total)
   is left alone to preserve dashboards.
 - setASWeight now takes the prior weight so the event can emit
   from=X to=Y and the UI can show the delta.
 - The vip field in every event is the bare address (no /32 or /128
   mask), matching the CLI output style.
 - Any listener on the gRPC WatchEvents stream — CLI watch events or
   maglev-frontend — now sees every VIP/AS dataplane change in real
   time without needing to raise the log level.

Build and tooling
 - Makefile: maglev-frontend added to BINARIES; build / build-amd64 /
   build-arm64 emit the binary alongside maglevd and maglevc. A new
   maglev-frontend-web target rebuilds the SolidJS bundle via npm.
 - web/dist/ is tracked so a bare `go build` keeps working for Go-only
   contributors and CI.
 - .gitignore skips cmd/frontend/web/node_modules/.

Stability fixes
 - maglevd's WatchEvents synthetic replay events (from==to, at_unix_ns=0)
   were corrupting the frontend's LastTransition cache with at=0,
   rendering as "20555d ago" in the browser. Client now skips synthetic
   events: the cache comes from refreshAll and doesn't need them.
 - Frontends, Backends, and HealthChecks are now served in the order
   returned by the corresponding List* RPC instead of Go map iteration
   order, so reloads and refreshes keep the SPA stable.
This commit is contained in:
2026-04-12 17:48:12 +02:00
parent fb62532fd5
commit 284b4cc9a4
42 changed files with 4366 additions and 35 deletions

View File

@@ -0,0 +1,40 @@
import 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 { lastTransitionAge } from "../stores/state";
type Props = {
maglevd: string;
backend: BackendSnapshot;
poolBackend: PoolBackendSnapshot;
};
const BackendRow: Component<Props> = (props) => {
const b = () => props.backend;
return (
<tr class="backend-row" data-state={b().state}>
<td class="backend-name">
<ProbeHeartbeat maglevd={props.maglevd} backend={b().name} />
{b().name}
{!b().enabled && <span class="tag">[disabled]</span>}
</td>
<td class="backend-address">{b().address}</td>
<td>
<Flash value={b().state}>
<StatusBadge state={b().state} />
</Flash>
</td>
<td class="numeric">
<Flash value={props.poolBackend.weight} />
</td>
<td class="numeric">
<Flash value={props.poolBackend.effective_weight} />
</td>
<td class="age">{lastTransitionAge(b().last_transition)}</td>
</tr>
);
};
export default BackendRow;

View File

@@ -0,0 +1,141 @@
import { For, createEffect, createMemo, createSignal, type Component } from "solid-js";
import Zippy from "../components/Zippy";
import { events } from "../stores/events";
import { scope } from "../stores/scope";
import type {
BackendEventPayload,
BrowserEvent,
FrontendEventPayload,
LogEventPayload,
} from "../types";
// DebugPanel is a collapsible rolling tail of recent events. Honors the
// current scope by default; a checkbox flips it into firehose mode.
const DebugPanel: Component = () => {
const [firehose, setFirehose] = createSignal(false);
const [paused, setPaused] = createSignal(false);
const [frozen, setFrozen] = createSignal<BrowserEvent[]>([]);
const filtered = createMemo(() => {
const list = paused() ? frozen() : events();
if (firehose()) return list;
const s = scope();
if (!s) return list;
return list.filter((e) => e.maglevd === s);
});
const togglePause = () => {
if (!paused()) {
setFrozen([...events()]);
setPaused(true);
} else {
setPaused(false);
}
};
// Tail behavior: whenever the list grows (or we unpause), scroll the
// event container to the bottom so the newest event stays visible. If
// paused, leave the scroll position alone so the operator can read.
let olRef: HTMLOListElement | undefined;
createEffect(() => {
filtered(); // track
if (paused()) return;
if (olRef) olRef.scrollTop = olRef.scrollHeight;
});
return (
<Zippy title="Event stream">
<ol class="event-tail" ref={olRef}>
<For each={filtered()}>
{(ev) => (
<li class={`event-row event-${ev.type}`} classList={{ "event-sync": isSyncEvent(ev) }}>
{formatEvent(ev)}
</li>
)}
</For>
</ol>
<div class="debug-toolbar">
<label>
<input
type="checkbox"
checked={firehose()}
onChange={(e) => setFirehose(e.currentTarget.checked)}
/>
all maglevds
</label>
<button onClick={togglePause}>{paused() ? "resume" : "pause"}</button>
<span class="count">{filtered().length} events</span>
</div>
</Zippy>
);
};
export default DebugPanel;
function isSyncEvent(ev: BrowserEvent): boolean {
if (ev.type !== "log") return false;
const p = ev.payload as LogEventPayload;
return p.msg.startsWith("vpp-lb-sync-");
}
// formatSyncAttrs renders vpp-lb-sync attributes in a fixed order so the
// event stream is easy to scan. Any key not explicitly listed is appended
// at the end preserving insertion order.
function formatSyncAttrs(attrs?: Record<string, string>): string {
if (!attrs) return "";
const order = [
"vip",
"protocol",
"port",
"address",
"weight",
"from",
"to",
"encap",
"src-ip-sticky",
"flush",
];
const parts: string[] = [];
const seen = new Set<string>();
for (const k of order) {
if (k in attrs) {
parts.push(`${k}=${attrs[k]}`);
seen.add(k);
}
}
for (const [k, v] of Object.entries(attrs)) {
if (!seen.has(k)) parts.push(`${k}=${v}`);
}
return parts.join(" ");
}
function formatEvent(ev: BrowserEvent): string {
const ts = new Date(ev.at_unix_ns / 1e6).toISOString().substring(11, 23);
const tag = `[${ev.maglevd}]`;
switch (ev.type) {
case "backend": {
const p = ev.payload as BackendEventPayload;
return `${ts} ${tag} backend ${p.backend}: ${p.transition.from}${p.transition.to}`;
}
case "frontend": {
const p = ev.payload as FrontendEventPayload;
return `${ts} ${tag} frontend ${p.frontend}: ${p.transition.from}${p.transition.to}`;
}
case "log": {
const p = ev.payload as LogEventPayload;
if (p.msg.startsWith("vpp-lb-sync-")) {
return `${ts} ${tag} ${p.msg} ${formatSyncAttrs(p.attrs)}`.trimEnd();
}
const attrs = p.attrs
? Object.entries(p.attrs)
.map(([k, v]) => `${k}=${v}`)
.join(" ")
: "";
return `${ts} ${tag} ${p.level} ${p.msg} ${attrs}`.trimEnd();
}
case "maglevd-status":
return `${ts} ${tag} maglevd status: ${JSON.stringify(ev.payload)}`;
default:
return `${ts} ${tag} ${ev.type}`;
}
}

View File

@@ -0,0 +1,66 @@
import { For, type Component } from "solid-js";
import type { FrontendSnapshot, StateSnapshot } from "../types";
import BackendRow from "./BackendRow";
type Props = {
snap: StateSnapshot;
frontend: FrontendSnapshot;
};
const FrontendCard: Component<Props> = (props) => {
const backendByName = () => Object.fromEntries(props.snap.backends.map((b) => [b.name, b]));
const fe = () => props.frontend;
return (
<section class="frontend-card">
<header class="frontend-header">
<h2>{fe().name}</h2>
<div class="frontend-meta">
<span class="addr">
{fe().address}:{fe().port}
</span>
<span class="proto">{fe().protocol.toUpperCase()}</span>
{fe().src_ip_sticky && <span class="tag">sticky</span>}
</div>
{fe().description && <p class="frontend-desc">{fe().description}</p>}
</header>
<For each={fe().pools}>
{(pool) => (
<div class="pool-block">
<h3 class="pool-name">pool: {pool.name}</h3>
<table class="backend-table">
<thead>
<tr>
<th>backend</th>
<th>address</th>
<th>state</th>
<th class="numeric">weight</th>
<th class="numeric">effective</th>
<th>last transition</th>
</tr>
</thead>
<tbody>
<For each={pool.backends}>
{(pb) => {
const backend = backendByName()[pb.name];
if (!backend) return null;
return (
<BackendRow
maglevd={props.snap.maglevd.name}
backend={backend}
poolBackend={pb}
/>
);
}}
</For>
</tbody>
</table>
</div>
)}
</For>
</section>
);
};
export default FrontendCard;

View File

@@ -0,0 +1,35 @@
import { For, Show, type Component } from "solid-js";
import { scope } from "../stores/scope";
import { state } from "../stores/state";
import FrontendCard from "./FrontendCard";
import VPPInfoPanel from "./VPPInfoPanel";
const Overview: Component = () => {
const snap = () => {
const s = scope();
return s ? state.byName[s] : undefined;
};
return (
<main class="overview">
<Show when={snap()} fallback={<p class="empty">No maglevd selected.</p>}>
{(s) => (
<>
<Show when={!s().maglevd.connected}>
<div class="banner warn">
{s().maglevd.name} disconnected
{s().maglevd.last_error && `: ${s().maglevd.last_error}`}
</div>
</Show>
<div class="frontend-grid">
<For each={s().frontends}>{(fe) => <FrontendCard snap={s()} frontend={fe} />}</For>
</div>
<VPPInfoPanel info={s().vpp_info} />
</>
)}
</Show>
</main>
);
};
export default Overview;

View File

@@ -0,0 +1,30 @@
import type { Component } from "solid-js";
import Zippy from "../components/Zippy";
import type { VPPInfoSnapshot } from "../types";
type Props = { info?: VPPInfoSnapshot };
const VPPInfoPanel: Component<Props> = (props) => {
if (!props.info) return null;
const i = props.info;
const boot = i.boottime_ns ? new Date(i.boottime_ns / 1e6).toISOString() : "";
const conn = i.connecttime_ns ? new Date(i.connecttime_ns / 1e6).toISOString() : "";
return (
<Zippy title="VPP information">
<dl class="kv">
<dt>version</dt>
<dd>{i.version}</dd>
<dt>build date</dt>
<dd>{i.build_date}</dd>
<dt>pid</dt>
<dd>{i.pid}</dd>
<dt>booted</dt>
<dd>{boot}</dd>
<dt>connected</dt>
<dd>{conn}</dd>
</dl>
</Zippy>
);
};
export default VPPInfoPanel;