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.
2 lines
5.0 KiB
CSS
2 lines
5.0 KiB
CSS
*,*:before,*:after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:14px;line-height:1.4;color:var(--fg);background:var(--bg)}h1,h2,h3,h4,p,dl,dd,ol,ul{margin:0;padding:0}ol,ul{list-style:none}a{color:inherit;text-decoration:none}button{font:inherit;color:inherit;background:none;border:1px solid var(--border);border-radius:4px;padding:4px 8px;cursor:pointer}button:hover{background:var(--bg-soft)}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:4px 8px}code,pre,.mono{font-family:SF Mono,Menlo,Consolas,monospace}:root{--bg: #fafafa;--bg-soft: #f0f0f0;--bg-card: #ffffff;--fg: #1f2937;--fg-muted: #6b7280;--border: #e5e7eb;--accent: #2563eb;--state-up: #16a34a;--state-down: #dc2626;--state-paused: #2563eb;--state-disabled: #6b7280;--state-unknown: #eab308;--state-removed: #374151}.flash-target{display:inline-block;padding:0 4px;border-radius:3px}.app{max-width:1400px;margin:0 auto;padding:16px}.app-header{display:flex;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand strong{font-size:18px}.app-header .mode-tag{margin-left:auto;padding:2px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;text-transform:uppercase}.brand .version{margin-left:8px;color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;cursor:help}.admin-toggle{padding:4px 10px;border:1px solid var(--border);border-radius:4px;color:var(--accent)}.scope-selector{display:flex;gap:6px;flex-wrap:wrap}.scope-tab{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:20px}.scope-tab.active{background:var(--accent);color:#fff;border-color:var(--accent)}.scope-tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--state-down)}.scope-tab.connected .dot{background:var(--state-up)}.status-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:500;color:#fff;text-transform:capitalize}.status-badge[data-state=up]{background:var(--state-up)}.status-badge[data-state=down]{background:var(--state-down)}.status-badge[data-state=paused]{background:var(--state-paused)}.status-badge[data-state=disabled]{background:var(--state-disabled)}.status-badge[data-state=unknown]{background:var(--state-unknown);color:#1f2937}.status-badge[data-state=removed]{background:var(--state-removed);text-decoration:line-through}.frontend-grid{display:grid;gap:16px;grid-template-columns:1fr}@media (min-width: 640px){.frontend-grid{grid-template-columns:1fr 1fr}}@media (min-width: 1024px){.frontend-grid{grid-template-columns:repeat(3,1fr)}}.frontend-card{background:var(--bg-card);border:1px solid var(--border);border-radius:6px;padding:12px}.frontend-header h2{font-size:16px;margin-bottom:4px}.frontend-meta{display:flex;gap:8px;color:var(--fg-muted);font-size:12px}.frontend-meta .proto{text-transform:uppercase;font-weight:600}.frontend-desc{font-size:12px;color:var(--fg-muted);margin-top:4px}.tag{display:inline-block;padding:1px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;margin-left:4px}.pool-block{margin-top:12px}.pool-name{font-size:13px;color:var(--fg-muted);margin-bottom:4px}.backend-table th,.backend-table td{white-space:nowrap}.backend-table th{font-size:11px;color:var(--fg-muted);text-transform:uppercase;border-bottom:1px solid var(--border)}.backend-table .numeric{text-align:right}.backend-row td{border-bottom:1px solid var(--border);font-size:13px}.backend-row .backend-name{font-weight:500}.backend-row .backend-address,.backend-row .age{color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:12px}.probe-heartbeat{display:inline-block;width:16px;height:14px;line-height:14px;margin-right:6px;text-align:center;font-size:10px;color:var(--state-disabled);overflow:hidden;vertical-align:middle}.probe-heartbeat.in-flight{color:inherit}.banner{padding:8px 12px;border-radius:4px;margin-bottom:12px;font-size:13px}.banner.warn{background:#fef3c7;color:#92400e}.banner.err{background:#fee2e2;color:#991b1b}.loading,.empty{color:var(--fg-muted);padding:16px}.zippy{margin-top:16px;border:1px solid var(--border);border-radius:6px;background:var(--bg-card)}.zippy summary{padding:8px 12px;cursor:pointer;font-weight:500}.zippy-body{padding:8px 12px;border-top:1px solid var(--border)}.kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px}.kv dt{color:var(--fg-muted)}.debug-toolbar{display:flex;gap:12px;align-items:center;margin-top:8px;font-size:12px}.debug-toolbar .count{margin-left:auto;color:var(--fg-muted)}.event-tail{max-height:320px;overflow:auto;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;line-height:1.5}.event-row{padding:2px 4px;white-space:pre-wrap;word-break:break-all}.event-row.event-backend{color:var(--state-up)}.event-row.event-frontend{color:var(--accent)}.event-row.event-log{color:var(--fg-muted)}.event-row.event-maglevd-status{color:var(--state-down)}.event-row.event-sync{color:var(--state-paused);font-weight:500}
|