Dataplane reconcile fixes; LB counters cleanup; SPA scope cookie

Checker / reload:
- Reload's update-in-place branch now mirrors b.Address onto the
  runtime health.Backend. Without this, GetBackend kept returning
  the pre-reload address indefinitely after a config edit that
  touched addresses but not healthcheck settings — the VPP sync
  path reads cfg.Backends directly so the dataplane moved on
  while the gRPC and SPA view stayed wedged on the old IPv4/IPv6.

Sync (internal/vpp/lbsync.go):
- reconcileVIP now detects encap mismatch in addition to
  src-ip-sticky mismatch and takes the full tear-down / re-add
  path via a new shared recreateVIP helper. Triggered when every
  backend flips address family (gre4 <-> gre6) and the existing
  VIP can no longer accept new ASes — previously the sync wedged
  with 'Invalid address family' until a full maglevd restart.
- setASWeight is issued whenever the state machine requests
  flush (a.Flush=true), not only on the weight-value transition
  edge. Fixes the case where a backend reached StateDisabled
  after its effective weight had already been drained to 0 by
  pool failover — the sticky-cache entries pointing at it were
  previously never cleared.

maglev-frontend:
- signal.Ignore(SIGHUP) so a controlling-terminal disconnect
  doesn't kill the daemon.
- debian/vpp-maglev.service grants CAP_SYS_ADMIN in addition to
  CAP_NET_RAW so setns(CLONE_NEWNET) can join the healthcheck
  netns. Comment documents the 'operation not permitted' symptom
  and notes the knob can be dropped if the deployment doesn't use
  the 'netns:' healthcheck option.

LB plugin counters (internal/vpp/lbstats.go + friends):
- Fix the VIP counter regex: the LB plugin registers
  vlib_simple_counter_main_t names without a leading '/'
  (vlib_validate_simple_counter in counter.c:50 uses cm->name
  verbatim; only entries that set cm->stat_segment_name get a
  slash). first/next/untracked/no-server now read through as
  live values instead of zero.
- Drop the per-backend FIB counter block end-to-end (proto,
  grpcapi, metrics, vpp.Client, lbstats, maglevc). Traced from
  lb/node.c:558 into ip{4,6}_forward.h:141 — the LB plugin
  forwards by writing adj_index[VLIB_TX] directly and bypassing
  ip{4,6}_lookup_inline, which is the only path that increments
  lbm_to_counters. The backend's FIB load_balance stats_index
  literally never ticks for LB-forwarded traffic, so the column
  was always zero and misleading. docs/implementation/TODO
  records the full investigation and the recommended upstream
  path (new lb_as_stats_dump API message) for when we're ready
  to carry that VPP patch.
- maglevc show vpp lb counters: plain-text tabular headers.
  label() wraps strings in ANSI escapes (~11 bytes of overhead),
  but tabwriter counts bytes, not rendered width — so a header
  row with label()'d cells and data rows with plain cells drifts
  column alignment on every row. color.go comment now spells
  out the constraint: label() only works when column N is
  wrapped identically in every row (key-value layouts are fine,
  multi-column tables with header-only labelling are not).

SPA:
- stores/scope.ts is cookie-backed (maglev_scope, 1 year,
  SameSite=Lax). App.tsx hydrates from the cookie then validates
  against the fetched snapshots: a cookie referencing a maglevd
  that no longer exists falls through to snaps[0] instead of
  leaving the user on a ghost selection.
- components/Flash.tsx wraps props.value in createMemo. Solid's
  on() fires its callback on every dep notification, not on
  value change — source is right in solid-js/dist/solid.js:460,
  no equality check. Without the memo, flipping scope between
  two 'connected' maglevds (or any other cross-store reactive
  re-eval that doesn't actually change the concrete string)
  replays the animation every time. createMemo's default ===
  dedupe fixes it in one place for every Flash consumer,
  superseding the local createMemo workaround we'd added in
  BackendRow earlier.
This commit is contained in:
2026-04-14 14:39:52 +02:00
parent 4288e22b71
commit 224167ce39
20 changed files with 435 additions and 471 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/view/favicon.ico" />
<title>maglev</title>
<script type="module" crossorigin src="/view/assets/index-DCJJqBMY.js"></script>
<script type="module" crossorigin src="/view/assets/index-3m4Pjc8_.js"></script>
<link rel="stylesheet" crossorigin href="/view/assets/index-3BvNJ7QB.css">
</head>
<body>

View File

@@ -19,7 +19,14 @@ const App: Component = () => {
const [snaps, ver] = await Promise.all([fetchAllState(), fetchVersion()]);
replaceAll(snaps);
setVersion(ver);
if (!scope() && snaps.length > 0) {
// Hydrate the scope: prefer a cookie-loaded value, but only if it
// still matches a maglevd we actually got back in the snapshot.
// Otherwise fall back to the first server in the list so the user
// never sees a "ghost selection" pointing at a maglevd that was
// removed or renamed since their last visit.
const current = scope();
const valid = current && snaps.some((s) => s.maglevd.name === current);
if (!valid && snaps.length > 0) {
setScope(snaps[0].maglevd.name);
}
openEventStream();

View File

@@ -1,4 +1,4 @@
import { createEffect, on, type Component, type JSX } from "solid-js";
import { createEffect, createMemo, on, type Component, type JSX } from "solid-js";
type Props = {
// value is used solely for change detection. When it changes the
@@ -26,9 +26,26 @@ type Props = {
const Flash: Component<Props> = (props) => {
let el: HTMLSpanElement | undefined;
// Solid's on() fires its callback whenever a tracked dep *notifies*,
// not whenever the tracked value actually changes — the source is
// right there in dist/solid.js:460, there's no equality check, just
// `fn(input, prevInput, prevValue)` on every notification. That's a
// problem for Flash because props.value is typically a reactive
// expression chained through the store (e.g. a per-maglevd
// vpp_state read), and changing an upstream signal like scope()
// causes every downstream reactive to re-evaluate even when the
// concrete value it produces is identical. Without this memo, the
// badge flashes every time you flip from one maglevd to another
// where both are 'connected'.
//
// createMemo gives us the equality dedupe we need: its default
// equality is ===, so an upstream notification that doesn't change
// the memoized output never propagates to the effect below.
const value = createMemo(() => props.value);
createEffect(
on(
() => props.value,
value,
() => {
el?.animate(
[

View File

@@ -1,6 +1,48 @@
import { createSignal } from "solid-js";
// The currently selected maglevd name, or undefined before first fetch.
const [scope, setScope] = createSignal<string | undefined>(undefined);
// Persisted selection of which maglevd the SPA is currently scoped to.
// The cookie is a best-effort hint: if it's missing, corrupt, or names a
// maglevd that no longer exists, we fall back to whatever App.tsx's
// hydration path picks (typically the first server in byName order).
// SameSite=Lax keeps it from leaking to third-party iframes; Max-Age is
// a year so the selection survives browser restarts.
const COOKIE_NAME = "maglev_scope";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
function readCookie(): string | undefined {
try {
const raw = document.cookie.split("; ").find((c) => c.startsWith(COOKIE_NAME + "="));
if (!raw) return undefined;
const value = decodeURIComponent(raw.slice(COOKIE_NAME.length + 1));
return value || undefined;
} catch {
return undefined;
}
}
function writeCookie(name: string | undefined) {
try {
if (!name) {
// Clear by setting Max-Age=0. Works across every mainstream browser.
document.cookie = `${COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`;
return;
}
const value = encodeURIComponent(name);
document.cookie = `${COOKIE_NAME}=${value}; Path=/; Max-Age=${COOKIE_MAX_AGE}; SameSite=Lax`;
} catch {
// quota / third-party-cookie blocks / private window — best effort
}
}
const [scope, setScopeRaw] = createSignal<string | undefined>(readCookie());
// setScope wraps the raw signal setter so every selection change writes
// back to the cookie. Callers use this exactly like the old setScope —
// the cookie plumbing is invisible at the call site.
function setScope(name: string | undefined) {
setScopeRaw(name);
writeCookie(name);
}
export { scope, setScope };

View File

@@ -377,12 +377,7 @@ export function applyConfiguredWeight(
// worse — typical maintenance / outage state
// "unknown" → fallthrough; should be unreachable, kept as
// a safety net for logic bugs in this function
export type FrontendHealth =
| "ok"
| "bug-buckets"
| "primary-drained"
| "degraded"
| "unknown";
export type FrontendHealth = "ok" | "bug-buckets" | "primary-drained" | "degraded" | "unknown";
export function frontendHealth(snap: StateSnapshot, fe: FrontendSnapshot): FrontendHealth {
const stateOf: Record<string, string> = {};

View File

@@ -17,9 +17,7 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
function readCookie(): Set<string> {
try {
const raw = document.cookie
.split("; ")
.find((c) => c.startsWith(COOKIE_NAME + "="));
const raw = document.cookie.split("; ").find((c) => c.startsWith(COOKIE_NAME + "="));
if (!raw) return new Set();
const value = decodeURIComponent(raw.slice(COOKIE_NAME.length + 1));
if (!value) return new Set();