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:
41
cmd/frontend/web/src/components/Flash.tsx
Normal file
41
cmd/frontend/web/src/components/Flash.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createEffect, on, type Component, type JSX } from "solid-js";
|
||||
|
||||
type Props = {
|
||||
// value is only used for change detection. When it changes the
|
||||
// wrapper runs a 1s flash animation.
|
||||
value: string | number | boolean;
|
||||
// When children are provided they are rendered inside the wrapper
|
||||
// instead of the raw value. Useful for wrapping e.g. <StatusBadge>
|
||||
// so the pill animates on state change while still showing itself.
|
||||
children?: JSX.Element;
|
||||
};
|
||||
|
||||
// Flash plays a 1s yellow-to-transparent background animation every
|
||||
// time `value` changes. The initial mount is skipped (defer: true) so
|
||||
// nothing flashes on page load. Uses the Web Animations API so repeated
|
||||
// changes reliably re-trigger even when the new value arrives while a
|
||||
// previous animation is still running.
|
||||
const Flash: Component<Props> = (props) => {
|
||||
let el: HTMLSpanElement | undefined;
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
() => {
|
||||
el?.animate([{ backgroundColor: "#fefe27" }, { backgroundColor: "transparent" }], {
|
||||
duration: 1000,
|
||||
easing: "ease-out",
|
||||
});
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<span ref={el} class="flash-target">
|
||||
{props.children ?? (props.value as unknown as JSX.Element)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Flash;
|
||||
32
cmd/frontend/web/src/components/ProbeHeartbeat.tsx
Normal file
32
cmd/frontend/web/src/components/ProbeHeartbeat.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createEffect, createSignal, type Component } from "solid-js";
|
||||
import { events } from "../stores/events";
|
||||
import type { LogEventPayload } from "../types";
|
||||
|
||||
type Props = { maglevd: string; backend: string };
|
||||
|
||||
// ProbeHeartbeat watches the event stream for probe-start/probe-done log
|
||||
// records targeted at this backend. It shows a heart while a probe is in
|
||||
// flight and a dot at rest. Success/failure is reflected by the backend's
|
||||
// state column, so this component is purely an activity indicator.
|
||||
const ProbeHeartbeat: Component<Props> = (props) => {
|
||||
const [inFlight, setInFlight] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
const list = events();
|
||||
if (list.length === 0) return;
|
||||
const ev = list[list.length - 1]; // newest — list is chronological
|
||||
if (ev.type !== "log" || ev.maglevd !== props.maglevd) return;
|
||||
const payload = ev.payload as LogEventPayload;
|
||||
if (payload.attrs?.backend !== props.backend) return;
|
||||
if (payload.msg === "probe-start") setInFlight(true);
|
||||
else if (payload.msg === "probe-done") setInFlight(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<span class="probe-heartbeat" classList={{ "in-flight": inFlight() }}>
|
||||
{inFlight() ? "\u2764\uFE0F" : "\u00B7"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProbeHeartbeat;
|
||||
34
cmd/frontend/web/src/components/ScopeSelector.tsx
Normal file
34
cmd/frontend/web/src/components/ScopeSelector.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { For, type Component } from "solid-js";
|
||||
import { scope, setScope } from "../stores/scope";
|
||||
import { state } from "../stores/state";
|
||||
|
||||
const ScopeSelector: Component = () => {
|
||||
const names = () => Object.keys(state.byName).sort();
|
||||
return (
|
||||
<nav class="scope-selector">
|
||||
<For each={names()}>
|
||||
{(name) => {
|
||||
const snap = () => state.byName[name];
|
||||
const connected = () => snap()?.maglevd.connected ?? false;
|
||||
return (
|
||||
<button
|
||||
class="scope-tab"
|
||||
classList={{
|
||||
active: scope() === name,
|
||||
connected: connected(),
|
||||
disconnected: !connected(),
|
||||
}}
|
||||
title={snap()?.maglevd.address ?? ""}
|
||||
onClick={() => setScope(name)}
|
||||
>
|
||||
<span class="dot" />
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScopeSelector;
|
||||
15
cmd/frontend/web/src/components/StatusBadge.tsx
Normal file
15
cmd/frontend/web/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Component } from "solid-js";
|
||||
|
||||
type Props = { state: string; label?: string };
|
||||
|
||||
// StatusBadge renders a state pill. Background color is a CSS custom
|
||||
// property on the :root so themes can override centrally.
|
||||
const StatusBadge: Component<Props> = (props) => {
|
||||
return (
|
||||
<span class="status-badge" data-state={props.state}>
|
||||
{props.label ?? props.state}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
18
cmd/frontend/web/src/components/Zippy.tsx
Normal file
18
cmd/frontend/web/src/components/Zippy.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Component, JSX } from "solid-js";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
open?: boolean;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
const Zippy: Component<Props> = (props) => {
|
||||
return (
|
||||
<details class="zippy" open={props.open}>
|
||||
<summary>{props.title}</summary>
|
||||
<div class="zippy-body">{props.children}</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
export default Zippy;
|
||||
Reference in New Issue
Block a user