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:
3
cmd/frontend/web/.prettierignore
Normal file
3
cmd/frontend/web/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
7
cmd/frontend/web/.prettierrc.json
Normal file
7
cmd/frontend/web/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
1
cmd/frontend/web/dist/assets/index-9NmAul22.css
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-9NmAul22.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/frontend/web/dist/assets/index-DZzDfClm.js
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-DZzDfClm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
cmd/frontend/web/dist/index.html
vendored
Normal file
13
cmd/frontend/web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>maglev</title>
|
||||
<script type="module" crossorigin src="/view/assets/index-DZzDfClm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/view/assets/index-9NmAul22.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
cmd/frontend/web/index.html
Normal file
12
cmd/frontend/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>maglev</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1695
cmd/frontend/web/package-lock.json
generated
Normal file
1695
cmd/frontend/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
cmd/frontend/web/package.json
Normal file
22
cmd/frontend/web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "maglev-frontend-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --outDir dist --emptyOutDir",
|
||||
"check": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-solid": "^2.10.2"
|
||||
}
|
||||
}
|
||||
62
cmd/frontend/web/src/App.tsx
Normal file
62
cmd/frontend/web/src/App.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createSignal, onMount, type Component } from "solid-js";
|
||||
import { fetchAllState, fetchVersion } from "./api/rest";
|
||||
import { openEventStream } from "./api/sse";
|
||||
import { replaceAll, state } from "./stores/state";
|
||||
import { scope, setScope } from "./stores/scope";
|
||||
import ScopeSelector from "./components/ScopeSelector";
|
||||
import Overview from "./views/Overview";
|
||||
import DebugPanel from "./views/DebugPanel";
|
||||
import type { VersionInfo } from "./types";
|
||||
|
||||
const isAdmin = window.location.pathname.startsWith("/admin");
|
||||
|
||||
const App: Component = () => {
|
||||
const [error, setError] = createSignal<string | undefined>();
|
||||
const [version, setVersion] = createSignal<VersionInfo | undefined>();
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [snaps, ver] = await Promise.all([fetchAllState(), fetchVersion()]);
|
||||
replaceAll(snaps);
|
||||
setVersion(ver);
|
||||
if (!scope() && snaps.length > 0) {
|
||||
setScope(snaps[0].maglevd.name);
|
||||
}
|
||||
openEventStream();
|
||||
} catch (err) {
|
||||
setError(`${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<strong>maglev</strong>
|
||||
{version() && (
|
||||
<span class="version" title={`commit ${version()!.commit} · built ${version()!.date}`}>
|
||||
{version()!.version} ({version()!.commit})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ScopeSelector />
|
||||
<span class="mode-tag">{isAdmin ? "admin" : "view"}</span>
|
||||
<a
|
||||
class="admin-toggle"
|
||||
href={isAdmin ? "/view/" : "/admin/"}
|
||||
title={isAdmin ? "exit admin mode" : "enter admin mode"}
|
||||
>
|
||||
{isAdmin ? "exit admin" : "admin…"}
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{error() && <div class="banner err">{error()}</div>}
|
||||
{!error() && Object.keys(state.byName).length === 0 && <p class="loading">Loading…</p>}
|
||||
|
||||
<Overview />
|
||||
<DebugPanel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
23
cmd/frontend/web/src/api/rest.ts
Normal file
23
cmd/frontend/web/src/api/rest.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { MaglevdInfo, StateSnapshot, VersionInfo } from "../types";
|
||||
|
||||
async function getJSON<T>(path: string): Promise<T> {
|
||||
const r = await fetch(path, { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error(`${path}: ${r.status} ${r.statusText}`);
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
export function listMaglevds(): Promise<MaglevdInfo[]> {
|
||||
return getJSON<MaglevdInfo[]>("/view/api/maglevds");
|
||||
}
|
||||
|
||||
export function fetchAllState(): Promise<StateSnapshot[]> {
|
||||
return getJSON<StateSnapshot[]>("/view/api/state");
|
||||
}
|
||||
|
||||
export function fetchState(name: string): Promise<StateSnapshot> {
|
||||
return getJSON<StateSnapshot>(`/view/api/state/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export function fetchVersion(): Promise<VersionInfo> {
|
||||
return getJSON<VersionInfo>("/view/api/version");
|
||||
}
|
||||
92
cmd/frontend/web/src/api/sse.ts
Normal file
92
cmd/frontend/web/src/api/sse.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
BackendEventPayload,
|
||||
BrowserEvent,
|
||||
FrontendEventPayload,
|
||||
LogEventPayload,
|
||||
MaglevdStatusPayload,
|
||||
} from "../types";
|
||||
import { fetchAllState } from "./rest";
|
||||
import {
|
||||
applyBackendEffectiveWeight,
|
||||
applyBackendTransition,
|
||||
applyFrontendTransition,
|
||||
applyMaglevdStatus,
|
||||
replaceAll,
|
||||
} from "../stores/state";
|
||||
import { pushEvent } from "../stores/events";
|
||||
|
||||
// openEventStream wires the SPA to /view/api/events. EventSource auto-
|
||||
// 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");
|
||||
|
||||
es.onmessage = (msg) => {
|
||||
try {
|
||||
const ev = JSON.parse(msg.data) as BrowserEvent;
|
||||
dispatch(ev);
|
||||
} catch (err) {
|
||||
console.error("sse parse error", err, msg.data);
|
||||
}
|
||||
};
|
||||
|
||||
// "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 () => {
|
||||
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;
|
||||
}
|
||||
|
||||
function dispatch(ev: BrowserEvent) {
|
||||
pushEvent(ev);
|
||||
switch (ev.type) {
|
||||
case "backend":
|
||||
applyBackendTransition(ev.maglevd, ev.payload as BackendEventPayload);
|
||||
break;
|
||||
case "frontend":
|
||||
applyFrontendTransition(ev.maglevd, ev.payload as FrontendEventPayload);
|
||||
break;
|
||||
case "maglevd-status":
|
||||
applyMaglevdStatus(ev.maglevd, ev.payload as MaglevdStatusPayload);
|
||||
break;
|
||||
case "log":
|
||||
applyLogEvent(ev.maglevd, ev.payload as LogEventPayload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// applyLogEvent surfaces the few log messages that carry data we want to
|
||||
// reflect in the store. Probe-start/probe-done drive the heartbeat and are
|
||||
// handled by BackendRow watching the events signal directly; here we only
|
||||
// react to VPP LB sync mutations so the effective weight column updates
|
||||
// live when a backend is disabled, enabled, or reweighted.
|
||||
function applyLogEvent(maglevd: string, p: LogEventPayload) {
|
||||
if (!p.msg.startsWith("vpp-lb-sync-as-")) return;
|
||||
const attrs = p.attrs ?? {};
|
||||
const address = attrs.address;
|
||||
if (!address) return;
|
||||
switch (p.msg) {
|
||||
case "vpp-lb-sync-as-added":
|
||||
applyBackendEffectiveWeight(maglevd, address, Number(attrs.weight ?? 0));
|
||||
break;
|
||||
case "vpp-lb-sync-as-removed":
|
||||
applyBackendEffectiveWeight(maglevd, address, 0);
|
||||
break;
|
||||
case "vpp-lb-sync-as-weight-updated":
|
||||
applyBackendEffectiveWeight(maglevd, address, Number(attrs.to ?? 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
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;
|
||||
9
cmd/frontend/web/src/main.tsx
Normal file
9
cmd/frontend/web/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
import "./styles/reset.css";
|
||||
import "./styles/theme.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("no #root element");
|
||||
render(() => <App />, root);
|
||||
21
cmd/frontend/web/src/stores/events.ts
Normal file
21
cmd/frontend/web/src/stores/events.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import type { BrowserEvent } from "../types";
|
||||
|
||||
// Rolling tail of recent events for the debug panel. Capped at 500 to
|
||||
// keep memory bounded on busy load balancers. Chronological order: the
|
||||
// oldest retained event is at index 0, the newest is at the end. The
|
||||
// DebugPanel renders in this order and auto-scrolls to the bottom so
|
||||
// the newest line stays in view (tail-style).
|
||||
const MAX = 500;
|
||||
|
||||
const [events, setEvents] = createSignal<BrowserEvent[]>([]);
|
||||
|
||||
export { events };
|
||||
|
||||
export function pushEvent(ev: BrowserEvent) {
|
||||
setEvents((prev) => {
|
||||
const next = [...prev, ev];
|
||||
if (next.length > MAX) return next.slice(next.length - MAX);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
6
cmd/frontend/web/src/stores/scope.ts
Normal file
6
cmd/frontend/web/src/stores/scope.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
// The currently selected maglevd name, or undefined before first fetch.
|
||||
const [scope, setScope] = createSignal<string | undefined>(undefined);
|
||||
|
||||
export { scope, setScope };
|
||||
107
cmd/frontend/web/src/stores/state.ts
Normal file
107
cmd/frontend/web/src/stores/state.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import type {
|
||||
BackendEventPayload,
|
||||
FrontendEventPayload,
|
||||
MaglevdStatusPayload,
|
||||
StateSnapshot,
|
||||
TransitionRecord,
|
||||
} from "../types";
|
||||
|
||||
// FrontendState keys snapshots by maglevd name. A single store drives the
|
||||
// whole UI; reducers produce() into the right branch.
|
||||
export type FrontendState = {
|
||||
byName: Record<string, StateSnapshot>;
|
||||
};
|
||||
|
||||
const [state, setState] = createStore<FrontendState>({ byName: {} });
|
||||
|
||||
export { state };
|
||||
|
||||
export function replaceSnapshot(snap: StateSnapshot) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.byName[snap.maglevd.name] = snap;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceAll(snaps: StateSnapshot[]) {
|
||||
const byName: Record<string, StateSnapshot> = {};
|
||||
for (const s of snaps) byName[s.maglevd.name] = s;
|
||||
setState({ byName });
|
||||
}
|
||||
|
||||
export function applyBackendTransition(maglevd: string, p: BackendEventPayload) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const snap = s.byName[maglevd];
|
||||
if (!snap) return;
|
||||
const b = snap.backends.find((x) => x.name === p.backend);
|
||||
if (!b) return;
|
||||
b.state = p.transition.to;
|
||||
b.last_transition = p.transition;
|
||||
if (!b.transitions) b.transitions = [];
|
||||
b.transitions.push(p.transition);
|
||||
if (b.transitions.length > 20) {
|
||||
b.transitions = b.transitions.slice(b.transitions.length - 20);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyFrontendTransition(maglevd: string, _p: FrontendEventPayload) {
|
||||
// Frontend roll-up state is computed per render in the current cut, so
|
||||
// there is nothing to update in the store. Kept as a named reducer so
|
||||
// the SSE dispatcher has one entry per event type and future frontend
|
||||
// state fields have a single place to land.
|
||||
void maglevd;
|
||||
}
|
||||
|
||||
export function applyMaglevdStatus(maglevd: string, p: MaglevdStatusPayload) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const snap = s.byName[maglevd];
|
||||
if (!snap) return;
|
||||
snap.maglevd.connected = p.connected;
|
||||
snap.maglevd.last_error = p.last_error;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// applyBackendEffectiveWeight updates the effective_weight of every pool
|
||||
// row that references the backend with the given address. Driven by the
|
||||
// vpp-lb-sync-as-* log events so the UI reflects VPP LB changes without
|
||||
// waiting for the 30s refresh tick.
|
||||
export function applyBackendEffectiveWeight(maglevd: string, address: string, weight: number) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const snap = s.byName[maglevd];
|
||||
if (!snap) return;
|
||||
const b = snap.backends.find((x) => x.address === address);
|
||||
if (!b) return;
|
||||
for (const fe of snap.frontends) {
|
||||
for (const pool of fe.pools) {
|
||||
for (const pb of pool.backends) {
|
||||
if (pb.name === b.name) {
|
||||
pb.effective_weight = weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Helpers used by views.
|
||||
|
||||
export function lastTransitionAge(t?: TransitionRecord): string {
|
||||
if (!t || !t.at_unix_ns || t.at_unix_ns <= 0) return "";
|
||||
const ms = Date.now() - t.at_unix_ns / 1e6;
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
64
cmd/frontend/web/src/styles/reset.css
Normal file
64
cmd/frontend/web/src/styles/reset.css
Normal file
@@ -0,0 +1,64 @@
|
||||
*,
|
||||
*::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;
|
||||
}
|
||||
334
cmd/frontend/web/src/styles/theme.css
Normal file
334
cmd/frontend/web/src/styles/theme.css
Normal file
@@ -0,0 +1,334 @@
|
||||
: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 ---- */
|
||||
|
||||
.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: white;
|
||||
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 ---- */
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
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 ---- */
|
||||
|
||||
.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 ---- */
|
||||
|
||||
/* Fixed-box wrapper so the row doesn't jiggle when the glyph swaps
|
||||
* between "·" (very narrow) and "❤️" (wide emoji with a different
|
||||
* font metric). Width is picked to comfortably contain the heart at
|
||||
* the declared font-size, line-height is locked so the emoji doesn't
|
||||
* push the row baseline, and overflow is hidden as a safety net in
|
||||
* case a platform renders the emoji even wider.
|
||||
*/
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ---- banners & loading ---- */
|
||||
|
||||
.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 {
|
||||
color: var(--fg-muted);
|
||||
padding: 16px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--fg-muted);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ---- zippy ---- */
|
||||
|
||||
.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 panel ---- */
|
||||
|
||||
.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;
|
||||
}
|
||||
107
cmd/frontend/web/src/types.ts
Normal file
107
cmd/frontend/web/src/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// TS mirror of cmd/frontend/types.go — keep in sync.
|
||||
|
||||
export type MaglevdInfo = {
|
||||
name: string;
|
||||
address: string;
|
||||
connected: boolean;
|
||||
last_error?: string;
|
||||
};
|
||||
|
||||
export type VersionInfo = {
|
||||
version: string;
|
||||
commit: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type TransitionRecord = {
|
||||
from: string;
|
||||
to: string;
|
||||
at_unix_ns: number;
|
||||
};
|
||||
|
||||
export type PoolBackendSnapshot = {
|
||||
name: string;
|
||||
weight: number;
|
||||
effective_weight: number;
|
||||
};
|
||||
|
||||
export type PoolSnapshot = {
|
||||
name: string;
|
||||
backends: PoolBackendSnapshot[];
|
||||
};
|
||||
|
||||
export type FrontendSnapshot = {
|
||||
name: string;
|
||||
address: string;
|
||||
protocol: string;
|
||||
port: number;
|
||||
description?: string;
|
||||
src_ip_sticky: boolean;
|
||||
pools: PoolSnapshot[];
|
||||
};
|
||||
|
||||
export type BackendSnapshot = {
|
||||
name: string;
|
||||
address: string;
|
||||
state: string;
|
||||
enabled: boolean;
|
||||
healthcheck: string;
|
||||
last_transition?: TransitionRecord;
|
||||
transitions?: TransitionRecord[];
|
||||
};
|
||||
|
||||
export type HealthCheckSnapshot = {
|
||||
name: string;
|
||||
type: string;
|
||||
port: number;
|
||||
interval_ns: number;
|
||||
fast_interval_ns: number;
|
||||
down_interval_ns: number;
|
||||
timeout_ns: number;
|
||||
rise: number;
|
||||
fall: number;
|
||||
};
|
||||
|
||||
export type VPPInfoSnapshot = {
|
||||
version: string;
|
||||
build_date: string;
|
||||
pid: number;
|
||||
boottime_ns: number;
|
||||
connecttime_ns: number;
|
||||
};
|
||||
|
||||
export type StateSnapshot = {
|
||||
maglevd: MaglevdInfo;
|
||||
frontends: FrontendSnapshot[];
|
||||
backends: BackendSnapshot[];
|
||||
healthchecks: HealthCheckSnapshot[];
|
||||
vpp_info?: VPPInfoSnapshot;
|
||||
};
|
||||
|
||||
export type BrowserEvent = {
|
||||
maglevd: string;
|
||||
type: "log" | "backend" | "frontend" | "maglevd-status" | "resync";
|
||||
at_unix_ns: number;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export type BackendEventPayload = {
|
||||
backend: string;
|
||||
transition: TransitionRecord;
|
||||
};
|
||||
|
||||
export type FrontendEventPayload = {
|
||||
frontend: string;
|
||||
transition: TransitionRecord;
|
||||
};
|
||||
|
||||
export type LogEventPayload = {
|
||||
level: string;
|
||||
msg: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type MaglevdStatusPayload = {
|
||||
connected: boolean;
|
||||
last_error?: string;
|
||||
};
|
||||
40
cmd/frontend/web/src/views/BackendRow.tsx
Normal file
40
cmd/frontend/web/src/views/BackendRow.tsx
Normal 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;
|
||||
141
cmd/frontend/web/src/views/DebugPanel.tsx
Normal file
141
cmd/frontend/web/src/views/DebugPanel.tsx
Normal 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}`;
|
||||
}
|
||||
}
|
||||
66
cmd/frontend/web/src/views/FrontendCard.tsx
Normal file
66
cmd/frontend/web/src/views/FrontendCard.tsx
Normal 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;
|
||||
35
cmd/frontend/web/src/views/Overview.tsx
Normal file
35
cmd/frontend/web/src/views/Overview.tsx
Normal 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;
|
||||
30
cmd/frontend/web/src/views/VPPInfoPanel.tsx
Normal file
30
cmd/frontend/web/src/views/VPPInfoPanel.tsx
Normal 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;
|
||||
22
cmd/frontend/web/tsconfig.json
Normal file
22
cmd/frontend/web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
23
cmd/frontend/web/vite.config.ts
Normal file
23
cmd/frontend/web/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
|
||||
// The app is served under /view/ by the Go binary (via http.StripPrefix),
|
||||
// which means Vite must emit asset URLs rooted there. For local `npm run
|
||||
// dev` outside the Go binary we also set server.base so the dev server
|
||||
// serves at /view/ and proxies API calls through to a running frontend.
|
||||
export default defineConfig({
|
||||
base: "/view/",
|
||||
plugins: [solid()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
target: "es2020",
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/view/api": "http://localhost:8080",
|
||||
"/admin": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user