Bug fixes, config validation, SPA tightening, set-weight UI
This session covers three distinct arcs: correctness bug fixes in the
VPP sync path and frontend reducers, new config validation, and a
large polish pass on the web frontend (tighter layout, backend kebab
dialogs, live grouped-table, live config-reload re-sync).
- encap for a VIP is now derived from the backend address family,
not the VIP's. A v6 VIP with v4 backends is programmed as IP6_GRE4
(not the buggy IP6_GRE6), matching the VPP LB plugin's
requirement that encap reflects the tunnel inner family. desiredVIP
gained an Encap field populated in desiredFromFrontend.
- ActivePoolIndex now requires at least one backend in a pool to be
BOTH in StateUp AND pb.Weight>0 before the pool counts as active.
Previously a primary pool with every backend manually zeroed would
still win over a fallback with weight=100, so fallback traffic
never materialized. New TestActivePoolIndexWeightedFailover table
pins the rule in five subcases.
- SyncLBStateVIP gained a flushAddress parameter threaded through
reconcileVIP; it forces flush=true on the setASWeight call for a
specific backend regardless of the usual 0→N heuristic. Wires up
the explicit [flush] knob the CLI exposes.
- convertFrontend already enforced that backends within one frontend
share a family. New cross-frontend pass validateVIPFamilyConsistency
rejects configs where two frontends share a VIP address but carry
backends in different families — VPP's LB plugin requires every
VIP on a prefix to have the same encap type, so such a config
would fail at lb_add_del_vip_v2 time with VNET_API_ERROR_INVALID
_ARGUMENT (-73). Catching it at config load turns a silent
runtime failure into a clear startup error.
- Two new TestValidationErrors cases pin the behavior: mismatched
families reject, same-family frontends on one VIP address allowed.
- Proto adds `bool flush = 5` to SetWeightRequest. The RPC now
drives a VIP sync immediately after mutating config (fixing the
latent "weight change only takes effect at the next 30s periodic
reconcile" gap), passing flushAddress = backend IP when req.Flush
is true.
- maglevc grows an optional [flush] token: `set frontend F pool P
backend B weight N [flush]`. Implementation uses two Run closures
(runSetFrontendPoolBackendWeight and -Flush) because the tree
walker only puts slot tokens in args — literal keywords like
`flush` advance the node but don't appear in the arg list.
- docs/user-guide.md updated with the [flush] optional and a
three-paragraph explainer of the graceful-drain vs. flush
semantics at the VPP level.
- checker.ListFrontends now sorts alphabetically to match the
existing sort in ListBackends / ListHealthChecks — RPC responses
no longer shuffle VIPs per call. cmd/frontend/client.go also
sorts defensively in refreshAll so an old maglevd build renders
alphabetically too.
- backendFromProto was returning out.Transitions[n-1] as the
LastTransition, but maglevd stores (and the proto carries)
transitions newest-first, so [n-1] was actually the oldest.
Reverse on read, which normalizes the client's Transitions slice
to oldest-first and makes [n-1] genuinely the newest. LastTransition
now points at the actual latest transition record.
- applyBackendTransition (Go and TS) derives Enabled = state!="disabled"
so the two fields stay in lockstep — closed a drift window where
a recently re-enabled backend still rendered with a stuck
[disabled] tag. The tag was later removed entirely since state
and enabled carry the same information.
- Layout tightened substantially: "FRONTENDS" panel header removed,
zippy-summary and zippy-body paddings cut, backend-table row
padding dropped to 2px, per-pool <h3> removed. Pools now live in
a single consolidated table per frontend with a dedicated "pool"
column that shows the pool name only on the first row of each
group — classic grouped-table layout, maximally dense.
- Description moved inline into the Zippy summary as muted italic
text, freeing a vertical line per frontend card.
- formatVIPAddress() helper renders IPv6 VIPs as [addr]:port and
IPv4 as addr:port, matching RFC 3986 authority syntax.
- Pools with effective_weight=0 on every backend (standby
fallbacks, fully-drained primaries) render at opacity 0.35 on
their non-actions cells; the kebab column stays at full contrast
because its menu is still fully functional on standby backends.
- Config-reload propagation: a maglevd config-reload-done log
event triggers triggerConfigResync() on the frontend side —
refreshAll() runs off the event-dispatch goroutine, then a
BrowserEvent{Type:"resync"} is published through the broker.
writeEvent emits type="resync" as a named SSE frame so the
SPA's existing addEventListener("resync") handler picks it up
and calls fetchAllState → replaceAll.
- recomputeEffectiveWeights in stores/state.ts mirrors the
server-side health.EffectiveWeights logic so the SPA keeps
pool.effective_weight correct the moment a backend transitions,
without waiting for the 30s refresh. Fixed a nasty bug where
applyBackendEffectiveWeight wrote VIP-scoped vpp-lb-sync-as-*
event weights into every frontend sharing the backend,
corrupting frontends with different per-pool configured weights.
The old log-event reducer was removed; applyConfiguredWeight is
the narrower replacement used by the kebab set-weight flow.
- applyBackendTransition calls recomputeEffectiveWeights after
state updates so pool-failover transitions (primary ⇌ fallback)
reflect instantly in the UI.
- Confirmation dialogs via a new Modal primitive
(Portal-mounted to document.body, escape/click-outside close,
click-outside debounced on mousedown so mid-row-text-selection
drags don't dismiss).
- pause/resume/enable/disable each show a Modal with a consequence
paragraph explaining what hits live traffic ("will keep existing
flows", "will flush VPP's flow table", etc.). The disable commit
button is styled btn-danger red.
- set-weight action shows a Modal with a range slider (0-100,
seeded from the current configured weight, accent-colored live
numeric readout via <output>) plus a flush checkbox and a live-
swapping note/warn paragraph describing what will happen. On
commit, the SPA also updates its local store via
applyConfiguredWeight so the operator sees the new weight
immediately without waiting for the next refresh.
- ProbeHeartbeat is now state-aware: ▶ (play) at rest for up/
down/unknown backends, ⏸ (pause) for paused, ⏹ (stop) for
disabled/removed, ❤️ (heart) during an in-flight probe.
- Drop the probe-done event listener — fast probes (<10ms)
could fire probe-done in the same render tick as probe-start
and the heart would never visibly paint. Each probe-start now
runs a fixed 400ms scale-pop animation on a timer; subsequent
probe-start events reset the timer, so fast cadences produce a
continuous heart pulse.
- Fixed wrapper box (16x14 px, overflow hidden) so the row
doesn't jiggle when the glyph swaps between the narrow ▶/⏸/⏹
text glyphs and the wider ❤️ emoji.
- Brand wordmark changed from "maglev" to "vpp-maglev" and wrapped
in an <a> linking to https://git.ipng.ch/ipng/vpp-maglev. Logo
link changed to https://ipng.ch/. Both open in a new tab with
rel="noopener".
- .gitignore fix: `frontend`, `maglevc`, `maglevd` were matching
ANY file or directory with those names anywhere in the tree,
silently ignoring cmd/frontend and friends. Anchored with
leading slashes so only repo-root build artifacts match.
This commit is contained in:
File diff suppressed because one or more lines are too long
1
cmd/frontend/web/dist/assets/index-BBNMNdtq.js
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-BBNMNdtq.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
1
cmd/frontend/web/dist/assets/index-CxDuAfMR.css
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-CxDuAfMR.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/frontend/web/dist/index.html
vendored
4
cmd/frontend/web/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<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-AsNHMKdQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/view/assets/index-CrBeXDdb.css">
|
||||
<script type="module" crossorigin src="/view/assets/index-BBNMNdtq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/view/assets/index-CxDuAfMR.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -39,14 +39,22 @@ const App: Component = () => {
|
||||
<div class="brand">
|
||||
<a
|
||||
class="brand-logo"
|
||||
href="https://git.ipng.ch/ipng/vpp-maglev/"
|
||||
href="https://ipng.ch/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="IPng Networks"
|
||||
>
|
||||
<img src={logoUrl} alt="IPng" />
|
||||
</a>
|
||||
<a
|
||||
class="brand-name"
|
||||
href="https://git.ipng.ch/ipng/vpp-maglev"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="vpp-maglev on git.ipng.ch"
|
||||
>
|
||||
<img src={logoUrl} alt="IPng" />
|
||||
<strong>vpp-maglev</strong>
|
||||
</a>
|
||||
<strong>maglev</strong>
|
||||
{version() && (
|
||||
<span class="version" title={`commit ${version()!.commit} · built ${version()!.date}`}>
|
||||
{version()!.version} ({version()!.commit})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BackendSnapshot } from "../types";
|
||||
import type { BackendSnapshot, FrontendSnapshot } from "../types";
|
||||
|
||||
export type BackendAction = "pause" | "resume" | "enable" | "disable";
|
||||
|
||||
@@ -22,3 +22,33 @@ export async function runBackendAction(
|
||||
}
|
||||
return (await r.json()) as BackendSnapshot;
|
||||
}
|
||||
|
||||
// Set a backend's weight within a specific frontend/pool. When flush
|
||||
// is true, VPP's flow table for the backend is cleared so existing
|
||||
// sessions are dropped; when false, only the new-buckets mapping is
|
||||
// updated and existing flows keep draining to the backend.
|
||||
export async function setBackendWeight(
|
||||
maglevd: string,
|
||||
frontend: string,
|
||||
pool: string,
|
||||
backend: string,
|
||||
weight: number,
|
||||
flush: boolean,
|
||||
): Promise<FrontendSnapshot> {
|
||||
const url =
|
||||
`/admin/api/${encodeURIComponent(maglevd)}` +
|
||||
`/frontend/${encodeURIComponent(frontend)}` +
|
||||
`/pool/${encodeURIComponent(pool)}` +
|
||||
`/backend/${encodeURIComponent(backend)}/weight`;
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ weight, flush }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = (await r.text()).trim();
|
||||
throw new Error(body || `${r.status} ${r.statusText}`);
|
||||
}
|
||||
return (await r.json()) as FrontendSnapshot;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ import type {
|
||||
BackendEventPayload,
|
||||
BrowserEvent,
|
||||
FrontendEventPayload,
|
||||
LogEventPayload,
|
||||
MaglevdStatusPayload,
|
||||
VPPStatusPayload,
|
||||
} from "../types";
|
||||
import { fetchAllState } from "./rest";
|
||||
import {
|
||||
applyBackendEffectiveWeight,
|
||||
applyBackendTransition,
|
||||
applyFrontendTransition,
|
||||
applyMaglevdStatus,
|
||||
@@ -56,6 +54,9 @@ function dispatch(ev: BrowserEvent) {
|
||||
pushEvent(ev);
|
||||
switch (ev.type) {
|
||||
case "backend":
|
||||
// The reducer also recomputes effective weights across every
|
||||
// frontend so pool-failover transitions (primary ⇌ fallback)
|
||||
// reflect instantly, without waiting for the 30s refresh.
|
||||
applyBackendTransition(ev.maglevd, ev.payload as BackendEventPayload);
|
||||
break;
|
||||
case "frontend":
|
||||
@@ -68,30 +69,17 @@ function dispatch(ev: BrowserEvent) {
|
||||
applyVPPStatus(ev.maglevd, (ev.payload as VPPStatusPayload).state);
|
||||
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));
|
||||
// Log events are displayed in the DebugPanel but no longer
|
||||
// mutate the state tree. The previous vpp-lb-sync-as-*
|
||||
// routing was removed because a VIP-scoped event was being
|
||||
// naively written into every frontend that shared the
|
||||
// backend, corrupting effective_weight for frontends with
|
||||
// different per-pool configured weights. Backend state
|
||||
// changes (arriving via "backend" events above) are a
|
||||
// sufficient trigger for recomputing effective weights
|
||||
// locally, and the set-weight kebab action updates the
|
||||
// store directly via applyConfiguredWeight on its POST
|
||||
// success path.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,96 @@
|
||||
import { For, Show, createEffect, createSignal, onCleanup, type Component } from "solid-js";
|
||||
import { runBackendAction, type BackendAction } from "../api/admin";
|
||||
import { runBackendAction, setBackendWeight, type BackendAction } from "../api/admin";
|
||||
import Modal from "./Modal";
|
||||
import { applyConfiguredWeight } from "../stores/state";
|
||||
|
||||
type Props = {
|
||||
maglevd: string;
|
||||
frontend: string;
|
||||
pool: string;
|
||||
backend: string;
|
||||
state: string;
|
||||
// Current configured weight. Used to seed the weight dialog's
|
||||
// number input so the operator sees the existing value and only
|
||||
// has to change the digits that matter.
|
||||
configuredWeight: number;
|
||||
};
|
||||
|
||||
// MenuAction is what the user clicks. It maps 1:1 onto BackendAction
|
||||
// plus a virtual "weight" entry that opens the weight form rather
|
||||
// than firing an immediate lifecycle RPC.
|
||||
type MenuAction = BackendAction | "weight";
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
action: BackendAction;
|
||||
action: MenuAction;
|
||||
};
|
||||
|
||||
// Action set available per current backend state. Only the actions
|
||||
// that make sense for the current state are shown — e.g. "resume" is
|
||||
// meaningless on a running backend, and "enable" is meaningless on
|
||||
// anything except a disabled one. A backend in the "removed" state
|
||||
// has no actionable operations, so the whole kebab is suppressed.
|
||||
// Available items per state. "weight" is always present (operators
|
||||
// can adjust the configured weight in any state including paused
|
||||
// and disabled — the effective weight won't change until the state
|
||||
// lets it, but the config value is still meaningful). A removed
|
||||
// backend has no actionable operations, so the kebab is suppressed
|
||||
// entirely by returning an empty list.
|
||||
function itemsForState(state: string): MenuItem[] {
|
||||
const weightItem: MenuItem = { label: "set weight…", action: "weight" };
|
||||
switch (state) {
|
||||
case "up":
|
||||
case "down":
|
||||
case "unknown":
|
||||
return [
|
||||
weightItem,
|
||||
{ label: "pause", action: "pause" },
|
||||
{ label: "disable", action: "disable" },
|
||||
];
|
||||
case "paused":
|
||||
return [
|
||||
weightItem,
|
||||
{ label: "resume", action: "resume" },
|
||||
{ label: "disable", action: "disable" },
|
||||
];
|
||||
case "disabled":
|
||||
return [{ label: "enable", action: "enable" }];
|
||||
return [weightItem, { label: "enable", action: "enable" }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// consequenceText returns the "this will…" description shown in the
|
||||
// confirmation dialog for each lifecycle action. Spelling it out in
|
||||
// plain English makes the live-traffic impact unmistakable before
|
||||
// the operator commits.
|
||||
function consequenceText(action: BackendAction): string {
|
||||
switch (action) {
|
||||
case "pause":
|
||||
return "This will stop health checks and set the weight to 0, but existing flows to this backend are kept. New traffic will be rerouted to other backends.";
|
||||
case "resume":
|
||||
return "This will restart health checks. The backend re-enters the 'unknown' state and will start receiving traffic once it probes up.";
|
||||
case "disable":
|
||||
return "This will stop health checks, set the weight to 0, AND flush VPP's flow table for this backend. Active sessions will be dropped immediately.";
|
||||
case "enable":
|
||||
return "This will restart health checks on a previously disabled backend. It re-enters the 'unknown' state and will start receiving traffic once it probes up.";
|
||||
}
|
||||
}
|
||||
|
||||
const BackendActionsMenu: Component<Props> = (props) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [busy, setBusy] = createSignal<BackendAction | undefined>();
|
||||
const [dialog, setDialog] = createSignal<MenuAction | undefined>();
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | undefined>();
|
||||
|
||||
// Weight-dialog form state. Seeded from the current configured
|
||||
// weight each time the dialog opens so re-opening after a change
|
||||
// shows the new value.
|
||||
const [weightInput, setWeightInput] = createSignal(props.configuredWeight);
|
||||
const [flushInput, setFlushInput] = createSignal(false);
|
||||
|
||||
let wrapRef: HTMLDivElement | undefined;
|
||||
|
||||
const items = () => itemsForState(props.state);
|
||||
|
||||
// Close on outside-click or Escape. The effect only installs its
|
||||
// document listeners while the menu is open, so there's no cost on
|
||||
// the typical closed-at-rest state.
|
||||
// Close the popover on outside click or Escape while it's open.
|
||||
// The dialog has its own Escape handler; this effect only runs
|
||||
// when the kebab popover (not the dialog) is visible.
|
||||
createEffect(() => {
|
||||
if (!open()) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
@@ -66,22 +108,59 @@ const BackendActionsMenu: Component<Props> = (props) => {
|
||||
});
|
||||
});
|
||||
|
||||
const run = async (action: BackendAction) => {
|
||||
setBusy(action);
|
||||
const openDialog = (action: MenuAction) => {
|
||||
setOpen(false);
|
||||
setError(undefined);
|
||||
if (action === "weight") {
|
||||
// Seed form from current value on each open.
|
||||
setWeightInput(props.configuredWeight);
|
||||
setFlushInput(false);
|
||||
}
|
||||
setDialog(action);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
if (busy()) return; // don't yank the modal out from under an in-flight call
|
||||
setDialog(undefined);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
const action = dialog();
|
||||
if (!action) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await runBackendAction(props.maglevd, props.backend, action);
|
||||
setOpen(false);
|
||||
if (action === "weight") {
|
||||
const w = weightInput();
|
||||
if (!Number.isFinite(w) || w < 0 || w > 100) {
|
||||
throw new Error("weight must be an integer in [0, 100]");
|
||||
}
|
||||
const newWeight = Math.floor(w);
|
||||
await setBackendWeight(
|
||||
props.maglevd,
|
||||
props.frontend,
|
||||
props.pool,
|
||||
props.backend,
|
||||
newWeight,
|
||||
flushInput(),
|
||||
);
|
||||
// Mirror the server-side mutation into our local store so
|
||||
// the new weight (and any resulting effective-weight
|
||||
// recompute) is visible instantly, without waiting for
|
||||
// the next 30s refresh tick.
|
||||
applyConfiguredWeight(props.maglevd, props.frontend, props.pool, props.backend, newWeight);
|
||||
} else {
|
||||
await runBackendAction(props.maglevd, props.backend, action);
|
||||
}
|
||||
setDialog(undefined);
|
||||
} catch (err) {
|
||||
setError(`${err}`);
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If there are no valid actions for the current state, render
|
||||
// nothing — the surrounding <td> stays an empty cell, no "dead"
|
||||
// kebab tempting clicks.
|
||||
return (
|
||||
<Show when={items().length > 0}>
|
||||
<div class="kebab-wrap" ref={wrapRef}>
|
||||
@@ -106,21 +185,110 @@ const BackendActionsMenu: Component<Props> = (props) => {
|
||||
type="button"
|
||||
class="kebab-item"
|
||||
role="menuitem"
|
||||
disabled={busy() !== undefined}
|
||||
onClick={() => run(item.action)}
|
||||
onClick={() => openDialog(item.action)}
|
||||
>
|
||||
{busy() === item.action ? `${item.label}…` : item.label}
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<p class="kebab-error">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={dialog()}>
|
||||
{(action) => (
|
||||
<Modal title={dialogTitle(action(), props.backend)} onClose={closeDialog}>
|
||||
{action() === "weight" ? (
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-target">
|
||||
<code>{props.backend}</code> in pool <code>{props.pool}</code> of frontend{" "}
|
||||
<code>{props.frontend}</code>
|
||||
</p>
|
||||
<label class="dialog-field">
|
||||
<span class="weight-slider-label">
|
||||
weight
|
||||
<output class="weight-slider-value">{weightInput()}</output>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
class="weight-slider"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={weightInput()}
|
||||
onInput={(e) => setWeightInput(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<small>0–100; 0 keeps the backend in the pool but assigns it no traffic</small>
|
||||
</label>
|
||||
<label class="dialog-field checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flushInput()}
|
||||
onChange={(e) => setFlushInput(e.currentTarget.checked)}
|
||||
/>
|
||||
<span>flush existing flows</span>
|
||||
</label>
|
||||
<Show
|
||||
when={flushInput()}
|
||||
fallback={
|
||||
<p class="dialog-note">
|
||||
VPP's flow table is left alone. Existing sessions keep reaching this backend
|
||||
until they finish.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p class="dialog-warn">
|
||||
VPP's flow table will be cleared for this backend. Active sessions will be
|
||||
dropped immediately.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
) : (
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-consequence">{consequenceText(action() as BackendAction)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Show when={error()}>
|
||||
<p class="dialog-error">{error()}</p>
|
||||
</Show>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onClick={closeDialog} disabled={busy()}>
|
||||
cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
classList={{
|
||||
"btn-primary": true,
|
||||
"btn-danger": action() === "disable" || (action() === "weight" && flushInput()),
|
||||
}}
|
||||
onClick={commit}
|
||||
disabled={busy()}
|
||||
>
|
||||
{busy() ? "committing…" : "commit"}
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
function dialogTitle(action: MenuAction, backend: string): string {
|
||||
switch (action) {
|
||||
case "weight":
|
||||
return `Set weight — ${backend}`;
|
||||
case "pause":
|
||||
return `Pause ${backend}?`;
|
||||
case "resume":
|
||||
return `Resume ${backend}?`;
|
||||
case "disable":
|
||||
return `Disable ${backend}?`;
|
||||
case "enable":
|
||||
return `Enable ${backend}?`;
|
||||
}
|
||||
}
|
||||
|
||||
export default BackendActionsMenu;
|
||||
|
||||
48
cmd/frontend/web/src/components/Modal.tsx
Normal file
48
cmd/frontend/web/src/components/Modal.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createEffect, onCleanup, type Component, type JSX } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
// Modal is a simple overlay primitive: a dark backdrop + a centered
|
||||
// card, portaled into document.body so it can't be clipped by a
|
||||
// table cell's overflow or trapped behind a z-index stack. Closes
|
||||
// on Escape, on explicit close-button click, or on backdrop click.
|
||||
// The backdrop listens on mousedown to decide whether the click
|
||||
// landed outside the card; that makes it robust against drags that
|
||||
// start inside and release outside (a user trying to select text).
|
||||
const Modal: Component<Props> = (props) => {
|
||||
createEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") props.onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
onCleanup(() => document.removeEventListener("keydown", onKey));
|
||||
});
|
||||
|
||||
return (
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) props.onClose();
|
||||
}}
|
||||
>
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-label={props.title}>
|
||||
<header class="modal-header">
|
||||
<h3>{props.title}</h3>
|
||||
<button type="button" class="modal-close" aria-label="close" onClick={props.onClose}>
|
||||
{"\u00D7"}
|
||||
</button>
|
||||
</header>
|
||||
<div class="modal-body">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -1,15 +1,51 @@
|
||||
import { createEffect, createSignal, type Component } from "solid-js";
|
||||
import { createEffect, createSignal, onCleanup, type Component } from "solid-js";
|
||||
import { events } from "../stores/events";
|
||||
import type { LogEventPayload } from "../types";
|
||||
|
||||
type Props = { maglevd: string; backend: string };
|
||||
type Props = {
|
||||
maglevd: string;
|
||||
backend: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
// Glyphs shown in the first column of every backend row. The at-rest
|
||||
// glyph reflects the backend's lifecycle state so the column does
|
||||
// double duty as a quick visual cue:
|
||||
//
|
||||
// ▶ active — will pop to a heart at each probe-start
|
||||
// ⏸ paused — health checks stopped via PauseBackend
|
||||
// ⏹ disabled / removed — no probes will run
|
||||
// ❤️ probe fired — shown for POP_DURATION_MS after each probe-start
|
||||
//
|
||||
// We only listen to probe-start, not probe-done: fast probes can
|
||||
// complete in <10 ms on local health checks, which means start and
|
||||
// done arrive in the same render tick and the heart never visibly
|
||||
// paints. Instead a single probe-start kicks off a fixed-length pop
|
||||
// sequence (heart visible + scale animation) driven by a timer.
|
||||
// Re-entering probe-start during the sequence cancels and restarts
|
||||
// the timer, so fast cadences produce a continuous pulse.
|
||||
const GLYPH_IDLE = "\u25B6"; // ▶
|
||||
const GLYPH_PAUSED = "\u23F8"; // ⏸
|
||||
const GLYPH_STOP = "\u23F9"; // ⏹
|
||||
const GLYPH_HEART = "\u2764\uFE0F"; // ❤️
|
||||
const POP_DURATION_MS = 400;
|
||||
|
||||
function idleGlyph(state: string): string {
|
||||
switch (state) {
|
||||
case "paused":
|
||||
return GLYPH_PAUSED;
|
||||
case "disabled":
|
||||
case "removed":
|
||||
return GLYPH_STOP;
|
||||
default:
|
||||
return GLYPH_IDLE;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
const [popping, setPopping] = createSignal(false);
|
||||
let el: HTMLSpanElement | undefined;
|
||||
let popTimer: number | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
const list = events();
|
||||
@@ -18,13 +54,39 @@ const ProbeHeartbeat: Component<Props> = (props) => {
|
||||
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);
|
||||
if (payload.msg !== "probe-start") return;
|
||||
|
||||
setPopping(true);
|
||||
// Scale-pop on appearance so the heart visually lands even for
|
||||
// <10 ms probes. The Web Animations API supersedes any still-
|
||||
// running anim on the next call, so fast back-to-back probes
|
||||
// re-trigger cleanly.
|
||||
el?.animate(
|
||||
[
|
||||
{ transform: "scale(1)" },
|
||||
{ transform: "scale(1.6)", offset: 0.25 },
|
||||
{ transform: "scale(1)" },
|
||||
],
|
||||
{ duration: POP_DURATION_MS, easing: "ease-out" },
|
||||
);
|
||||
// Each new probe-start resets the off-timer so the heart stays
|
||||
// visible for the full pop duration. Fast cadences keep the
|
||||
// heart continuously on; slow ones get a clean heart-then-idle
|
||||
// transition when the timer expires.
|
||||
if (popTimer !== undefined) clearTimeout(popTimer);
|
||||
popTimer = window.setTimeout(() => {
|
||||
setPopping(false);
|
||||
popTimer = undefined;
|
||||
}, POP_DURATION_MS);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (popTimer !== undefined) clearTimeout(popTimer);
|
||||
});
|
||||
|
||||
return (
|
||||
<span class="probe-heartbeat" classList={{ "in-flight": inFlight() }}>
|
||||
{inFlight() ? "\u2764\uFE0F" : "\u00B7"}
|
||||
<span ref={el} class="probe-heartbeat" classList={{ "in-flight": popping() }}>
|
||||
{popping() ? GLYPH_HEART : idleGlyph(props.state)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,48 @@ import type {
|
||||
} from "../types";
|
||||
import { tick } from "./tick";
|
||||
|
||||
// recomputeEffectiveWeights mirrors the server-side
|
||||
// health.EffectiveWeights / ActivePoolIndex logic so the SPA can keep
|
||||
// pool.effective_weight correct the moment a backend transitions,
|
||||
// without waiting for the 30s refresh. Walking every frontend is cheap
|
||||
// — O(frontends × pools × backends-per-pool) with tiny constants —
|
||||
// and it's strictly a function of the backend state map, so there's no
|
||||
// risk of drift vs. the server as long as the rule stays the same.
|
||||
//
|
||||
// Rule: a backend gets its configured pool weight iff it is up AND
|
||||
// belongs to the currently-active pool; everything else is 0. The
|
||||
// active pool is the first pool containing a backend that is both
|
||||
// up AND has a non-zero configured weight — a pool whose up backends
|
||||
// are all weight=0 contributes no serving capacity and gets skipped
|
||||
// over in priority failover. Kept in lock-step with
|
||||
// internal/health/weights.go.
|
||||
function recomputeEffectiveWeights(snap: StateSnapshot) {
|
||||
const stateOf: Record<string, string> = {};
|
||||
for (const b of snap.backends) stateOf[b.name] = b.state;
|
||||
for (const fe of snap.frontends) {
|
||||
let activePool = 0;
|
||||
for (let i = 0; i < fe.pools.length; i++) {
|
||||
let anyServing = false;
|
||||
for (const pb of fe.pools[i].backends) {
|
||||
if (stateOf[pb.name] === "up" && pb.weight > 0) {
|
||||
anyServing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (anyServing) {
|
||||
activePool = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < fe.pools.length; i++) {
|
||||
for (const pb of fe.pools[i].backends) {
|
||||
const st = stateOf[pb.name];
|
||||
pb.effective_weight = st === "up" && i === activePool ? pb.weight : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FrontendState keys snapshots by maglevd name. A single store drives the
|
||||
// whole UI; reducers produce() into the right branch.
|
||||
export type FrontendState = {
|
||||
@@ -40,12 +82,25 @@ export function applyBackendTransition(maglevd: string, p: BackendEventPayload)
|
||||
const b = snap.backends.find((x) => x.name === p.backend);
|
||||
if (!b) return;
|
||||
b.state = p.transition.to;
|
||||
// Derive enabled from state — see the matching comment in
|
||||
// cmd/frontend/client.go applyBackendTransition. state="disabled"
|
||||
// and enabled=false are two expressions of the same condition
|
||||
// in maglevd, so keeping them in sync locally closes a drift
|
||||
// window where the UI would show the wrong [disabled] tag.
|
||||
b.enabled = p.transition.to !== "disabled";
|
||||
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);
|
||||
}
|
||||
// A backend state change can shift which pool is active and
|
||||
// therefore which pool-memberships get non-zero effective
|
||||
// weights. Recompute for every frontend — not just the one
|
||||
// pointed at by this backend — because pool-failover is a
|
||||
// per-frontend computation and the same backend can appear in
|
||||
// multiple frontends with different pool placements.
|
||||
recomputeEffectiveWeights(snap);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -83,32 +138,50 @@ export function applyMaglevdStatus(maglevd: string, p: MaglevdStatusPayload) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// applyConfiguredWeight updates the configured weight of a specific
|
||||
// backend's pool-membership within a named frontend/pool, then
|
||||
// recomputes effective weights so pool-failover semantics stay
|
||||
// consistent. Called from the BackendActionsMenu after a successful
|
||||
// admin "set weight" POST so the UI reflects the change instantly
|
||||
// without waiting for the 30s refresh tick. Unlike the previous
|
||||
// log-event-driven reducer, this one is scoped to exactly the
|
||||
// pool-membership the operator edited, so it can't leak weights
|
||||
// across frontends that share the backend.
|
||||
export function applyConfiguredWeight(
|
||||
maglevd: string,
|
||||
frontend: string,
|
||||
pool: string,
|
||||
backend: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fe = snap.frontends.find((f) => f.name === frontend);
|
||||
if (!fe) return;
|
||||
const p = fe.pools.find((x) => x.name === pool);
|
||||
if (!p) return;
|
||||
const pb = p.backends.find((x) => x.name === backend);
|
||||
if (!pb) return;
|
||||
pb.weight = weight;
|
||||
recomputeEffectiveWeights(snap);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Helpers used by views.
|
||||
|
||||
// formatVIPAddress renders an address:port string with IPv6 addresses
|
||||
// wrapped in square brackets. This matches the URL-authority
|
||||
// convention (RFC 3986 §3.2.2) — without the brackets the colons in
|
||||
// an IPv6 literal are ambiguous against the port separator. IPv4 is
|
||||
// left bare.
|
||||
export function formatVIPAddress(address: string, port: number): string {
|
||||
if (address.includes(":")) return `[${address}]:${port}`;
|
||||
return `${address}:${port}`;
|
||||
}
|
||||
|
||||
export function lastTransitionAge(t?: TransitionRecord): string {
|
||||
// Subscribe to the 1s ticker so the age string updates live as a
|
||||
// real-time countdown. No effect on layout — the age column is
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
--bg: #fafafa;
|
||||
--bg-soft: #f0f0f0;
|
||||
--bg-card: #ffffff;
|
||||
--fg: #1f2937;
|
||||
--fg: #0f172a;
|
||||
--fg-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--accent: #2563eb;
|
||||
@@ -51,6 +51,13 @@
|
||||
.brand strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
.brand-name {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand-name:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -149,51 +156,44 @@
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ---- frontend grid ---- */
|
||||
/* ---- frontend list ---- */
|
||||
|
||||
.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-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Zippy summary row for a frontend. Flex so every piece sits on a
|
||||
* single line with consistent spacing, and flex-wrap lets a narrow
|
||||
* viewport wrap cleanly rather than overflow. */
|
||||
.frontend-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.frontend-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
color: var(--fg-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.frontend-meta .proto {
|
||||
text-transform: uppercase;
|
||||
.frontend-title-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.frontend-desc {
|
||||
.frontend-title-addr {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.frontend-title-proto {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.frontend-title-desc {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
@@ -205,15 +205,17 @@
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.pool-block {
|
||||
margin-top: 12px;
|
||||
/* Fixed table layout with per-column widths so every backend table
|
||||
* renders identical columns regardless of which pool/frontend it
|
||||
* lives in. The name column is the only auto-sized one, so it
|
||||
* absorbs the remaining space — and since all tables have the same
|
||||
* width (100% of the same zippy body width) and the same fixed
|
||||
* column sums, the auto column is identical in all of them, and
|
||||
* every column aligns vertically across pools and frontends. */
|
||||
.backend-table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
.pool-name {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.backend-table th,
|
||||
.backend-table td {
|
||||
white-space: nowrap;
|
||||
@@ -223,13 +225,83 @@
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.backend-table .numeric {
|
||||
text-align: right;
|
||||
}
|
||||
/* Pool-name column. Rendered only on the first row of each pool
|
||||
* group; subsequent rows within the same pool leave this cell
|
||||
* blank, producing a classic grouped-table look where the pool
|
||||
* label appears once above its members. Wide enough to fit
|
||||
* realistic pool names like "primary"/"fallback"/"canary".
|
||||
*
|
||||
* The header (<th>) keeps its default bold + uppercase + muted
|
||||
* styling from .backend-table th; we only style the data cells
|
||||
* here so the column still reads like every other header. */
|
||||
.backend-table .col-pool {
|
||||
width: 10ch;
|
||||
}
|
||||
.backend-row .col-pool {
|
||||
color: var(--fg-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Standby rows: every backend in this row's pool has
|
||||
* effective_weight=0, meaning the pool isn't currently carrying
|
||||
* traffic (standby fallback or fully-drained primary). Dims
|
||||
* every column EXCEPT the actions cell, leaving the kebab icon,
|
||||
* its popover menu, and the modal dialogs it opens at full
|
||||
* contrast — those are all still functional (an operator can
|
||||
* pause/disable/set-weight on a standby backend just as easily
|
||||
* as on an active one), and dimming them would misleadingly
|
||||
* imply they're disabled. Opacity is also multiplicative
|
||||
* through the DOM tree, so excluding the td boundary keeps the
|
||||
* popover and modal fully opaque regardless of where their
|
||||
* DOM ends up mounted.
|
||||
*
|
||||
* Combined with the darker --fg base colour on active rows,
|
||||
* 0.35 gives a clear two-tier contrast. */
|
||||
.backend-row.pool-standby > td:not(.actions) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
/* Sized to comfortably fit the longest legitimate IPv6 form
|
||||
* (e.g. 2001:0db8:85a3:0000:0000:8a2e:0370:7334, 39 chars) plus a
|
||||
* little slack. Shorter IPv4 addresses just leave extra room
|
||||
* before the state column, which is cheaper than clipping. */
|
||||
.backend-table .col-address {
|
||||
width: 42ch;
|
||||
}
|
||||
.backend-table .col-state {
|
||||
width: 90px;
|
||||
}
|
||||
.backend-table .col-weight {
|
||||
width: 80px;
|
||||
}
|
||||
.backend-table .col-effective {
|
||||
width: 95px;
|
||||
}
|
||||
.backend-table .col-age {
|
||||
width: 110px;
|
||||
}
|
||||
/* The name column clips with an ellipsis on the inner span rather
|
||||
* than the td itself so the Flash halo box-shadow (which uses
|
||||
* overflow: visible on td) still escapes the cell on adjacent
|
||||
* numeric/state cells. */
|
||||
.backend-row td.backend-name {
|
||||
overflow: hidden;
|
||||
}
|
||||
.backend-name-text {
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 22px); /* leave room for the heartbeat wrapper */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.backend-row td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.backend-row .backend-name {
|
||||
font-weight: 500;
|
||||
@@ -242,7 +314,7 @@
|
||||
}
|
||||
.backend-table th.actions,
|
||||
.backend-row td.actions {
|
||||
width: 24px;
|
||||
width: 28px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -310,14 +382,219 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* ---- modal ---- */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
.modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg);
|
||||
}
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ---- backend-action dialog contents ---- */
|
||||
|
||||
.dialog-body {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dialog-target {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.dialog-target code {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
background: var(--bg-soft);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.dialog-consequence {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--fg);
|
||||
}
|
||||
.dialog-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dialog-field > span {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dialog-field input[type="number"] {
|
||||
font: inherit;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
width: 100px;
|
||||
}
|
||||
.dialog-field .weight-slider-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dialog-field .weight-slider-value {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
}
|
||||
.dialog-field .weight-slider {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.dialog-field small {
|
||||
margin-top: 4px;
|
||||
color: var(--fg-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.dialog-field.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.dialog-field.checkbox > span {
|
||||
margin-bottom: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.dialog-note {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
line-height: 1.5;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dialog-warn {
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
line-height: 1.5;
|
||||
padding: 8px 10px;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dialog-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
font: inherit;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* The background is set explicitly on :hover to override the generic
|
||||
* button:hover rule in reset.css. Without this, .btn-primary:hover
|
||||
* would only override `filter` and the generic rule's near-white
|
||||
* var(--bg-soft) background would still apply, making a blue button
|
||||
* turn white on hover. */
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
.btn-primary.btn-danger {
|
||||
background: var(--state-down);
|
||||
border-color: var(--state-down);
|
||||
}
|
||||
.btn-primary.btn-danger:hover:not(:disabled) {
|
||||
background: var(--state-down);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* ---- 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.
|
||||
* between the text-style play/pause/stop glyphs (▶ ⏸ ⏹) and the
|
||||
* wider heart emoji (❤️). The scale-pop animation uses
|
||||
* transform-origin: center so the glyph expands in place. Overflow
|
||||
* is hidden as a safety net in case a platform renders the emoji
|
||||
* wider than the box.
|
||||
*/
|
||||
.probe-heartbeat {
|
||||
display: inline-block;
|
||||
@@ -326,13 +603,15 @@
|
||||
line-height: 14px;
|
||||
margin-right: 6px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--state-disabled);
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.probe-heartbeat.in-flight {
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- banners & loading ---- */
|
||||
@@ -363,18 +642,17 @@
|
||||
/* ---- zippy ---- */
|
||||
|
||||
.zippy {
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.zippy summary {
|
||||
padding: 8px 12px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.zippy-body {
|
||||
padding: 8px 12px;
|
||||
padding: 4px 10px 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.zippy-title {
|
||||
|
||||
@@ -9,6 +9,18 @@ import { isAdmin } from "../stores/mode";
|
||||
|
||||
type Props = {
|
||||
maglevd: string;
|
||||
frontend: string;
|
||||
pool: string;
|
||||
// showPool=true renders the pool name in the first column. Set
|
||||
// only on the first backend row of each pool; subsequent rows in
|
||||
// the same pool leave the cell blank, giving the "grouped table"
|
||||
// look where the pool label appears once above its members.
|
||||
showPool: boolean;
|
||||
// poolActive=false means every backend in this row's pool has
|
||||
// effective_weight=0 right now — a standby fallback or a fully
|
||||
// drained primary. The row is rendered dimmer so the operator
|
||||
// can scan which pool is actually carrying traffic.
|
||||
poolActive: boolean;
|
||||
backend: BackendSnapshot;
|
||||
poolBackend: PoolBackendSnapshot;
|
||||
};
|
||||
@@ -16,11 +28,15 @@ type Props = {
|
||||
const BackendRow: Component<Props> = (props) => {
|
||||
const b = () => props.backend;
|
||||
return (
|
||||
<tr class="backend-row" data-state={b().state}>
|
||||
<tr
|
||||
class="backend-row"
|
||||
classList={{ "pool-standby": !props.poolActive }}
|
||||
data-state={b().state}
|
||||
>
|
||||
<td class="col-pool">{props.showPool ? props.pool : ""}</td>
|
||||
<td class="backend-name">
|
||||
<ProbeHeartbeat maglevd={props.maglevd} backend={b().name} />
|
||||
{b().name}
|
||||
{!b().enabled && <span class="tag">[disabled]</span>}
|
||||
<ProbeHeartbeat maglevd={props.maglevd} backend={b().name} state={b().state} />
|
||||
<span class="backend-name-text">{b().name}</span>
|
||||
</td>
|
||||
<td class="backend-address">{b().address}</td>
|
||||
<td>
|
||||
@@ -37,7 +53,14 @@ const BackendRow: Component<Props> = (props) => {
|
||||
<td class="age">{lastTransitionAge(b().last_transition)}</td>
|
||||
<Show when={isAdmin}>
|
||||
<td class="actions">
|
||||
<BackendActionsMenu maglevd={props.maglevd} backend={b().name} state={b().state} />
|
||||
<BackendActionsMenu
|
||||
maglevd={props.maglevd}
|
||||
frontend={props.frontend}
|
||||
pool={props.pool}
|
||||
backend={b().name}
|
||||
state={b().state}
|
||||
configuredWeight={props.poolBackend.weight}
|
||||
/>
|
||||
</td>
|
||||
</Show>
|
||||
</tr>
|
||||
|
||||
@@ -3,74 +3,93 @@ import type { FrontendSnapshot, StateSnapshot } from "../types";
|
||||
import BackendRow from "./BackendRow";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
snap: StateSnapshot;
|
||||
frontend: FrontendSnapshot;
|
||||
};
|
||||
|
||||
// FrontendCard is rendered as a Zippy so a deployment with many VIPs
|
||||
// can collapse the frontends it doesn't care about. The title line
|
||||
// carries the frontend name, a live state badge, address:port, the
|
||||
// protocol, the sticky marker, and (when set) the description.
|
||||
//
|
||||
// The body renders a single consolidated backend table with one row
|
||||
// per (pool, backend). The first column holds the pool name, shown
|
||||
// only on the first row of each pool and blank on subsequent rows —
|
||||
// a classic grouped-table layout that keeps rows dense while still
|
||||
// making pool grouping instantly scannable.
|
||||
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}
|
||||
<Flash value={fe().state ?? "unknown"}>
|
||||
<StatusBadge state={fe().state ?? "unknown"} />
|
||||
</Flash>
|
||||
</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>
|
||||
const title = (
|
||||
<span class="frontend-title">
|
||||
<span class="frontend-title-name">{fe().name}</span>
|
||||
<Flash value={fe().state ?? "unknown"}>
|
||||
<StatusBadge state={fe().state ?? "unknown"} />
|
||||
</Flash>
|
||||
<span class="frontend-title-addr">{formatVIPAddress(fe().address, fe().port)}</span>
|
||||
<span class="frontend-title-proto">{fe().protocol.toUpperCase()}</span>
|
||||
{fe().src_ip_sticky && <span class="tag">sticky</span>}
|
||||
{fe().description && <span class="frontend-title-desc">{fe().description}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
<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>
|
||||
<Show when={isAdmin}>
|
||||
<th class="actions" />
|
||||
</Show>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
return (
|
||||
<Zippy title={title} open>
|
||||
<table class="backend-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-pool">pool</th>
|
||||
<th class="col-name">backend</th>
|
||||
<th class="col-address">address</th>
|
||||
<th class="col-state">state</th>
|
||||
<th class="col-weight numeric">weight</th>
|
||||
<th class="col-effective numeric">effective</th>
|
||||
<th class="col-age">last transition</th>
|
||||
<Show when={isAdmin}>
|
||||
<th class="col-actions actions" />
|
||||
</Show>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={fe().pools}>
|
||||
{(pool) => {
|
||||
// A pool is "active" when at least one of its backends
|
||||
// is currently serving traffic (effective_weight > 0).
|
||||
// Inactive pools — standby fallbacks, fully-drained
|
||||
// primaries — have every row rendered dimmer so the
|
||||
// operator can see at a glance which pool is actually
|
||||
// carrying traffic right now.
|
||||
const poolActive = () => pool.backends.some((pb) => pb.effective_weight > 0);
|
||||
return (
|
||||
<For each={pool.backends}>
|
||||
{(pb) => {
|
||||
{(pb, idx) => {
|
||||
const backend = backendByName()[pb.name];
|
||||
if (!backend) return null;
|
||||
return (
|
||||
<BackendRow
|
||||
maglevd={props.snap.maglevd.name}
|
||||
frontend={fe().name}
|
||||
pool={pool.name}
|
||||
showPool={idx() === 0}
|
||||
poolActive={poolActive()}
|
||||
backend={backend}
|
||||
poolBackend={pb}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Zippy>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const Overview: Component = () => {
|
||||
{s().maglevd.last_error && `: ${s().maglevd.last_error}`}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="frontend-grid">
|
||||
<div class="frontend-list">
|
||||
<For each={s().frontends}>{(fe) => <FrontendCard snap={s()} frontend={fe} />}</For>
|
||||
</div>
|
||||
<VPPInfoPanel info={s().vpp_info} state={s().vpp_state} />
|
||||
|
||||
Reference in New Issue
Block a user