From 4347bb9b0517310624ebc95e69da7ecf21b6cf98 Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Sun, 12 Apr 2026 23:06:38 +0200 Subject: [PATCH] Bug fixes, config validation, SPA tightening, set-weight UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

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 ) 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 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. --- .gitignore | 6 + cmd/frontend/client.go | 98 ++++- cmd/frontend/handlers.go | 87 +++- .../web/dist/assets/index-AsNHMKdQ.js | 1 - .../web/dist/assets/index-BBNMNdtq.js | 1 + .../web/dist/assets/index-CrBeXDdb.css | 1 - .../web/dist/assets/index-CxDuAfMR.css | 1 + cmd/frontend/web/dist/index.html | 4 +- cmd/frontend/web/src/App.tsx | 14 +- cmd/frontend/web/src/api/admin.ts | 32 +- cmd/frontend/web/src/api/sse.ts | 40 +- .../web/src/components/BackendActionsMenu.tsx | 220 ++++++++-- cmd/frontend/web/src/components/Modal.tsx | 48 +++ .../web/src/components/ProbeHeartbeat.tsx | 84 +++- cmd/frontend/web/src/stores/state.ts | 105 ++++- cmd/frontend/web/src/styles/theme.css | 390 +++++++++++++++--- cmd/frontend/web/src/views/BackendRow.tsx | 33 +- cmd/frontend/web/src/views/FrontendCard.tsx | 107 +++-- cmd/frontend/web/src/views/Overview.tsx | 2 +- cmd/maglevc/commands.go | 39 +- cmd/maglevc/tree_test.go | 1 + docs/user-guide.md | 26 +- internal/checker/checker.go | 1 + internal/config/config.go | 79 ++++ internal/config/config_test.go | 80 ++++ internal/grpcapi/maglev.pb.go | 27 +- internal/grpcapi/server.go | 30 +- internal/health/weights.go | 15 +- internal/health/weights_test.go | 86 ++++ internal/vpp/lbsync.go | 44 +- internal/vpp/lbsync_test.go | 261 ++++++++++++ internal/vpp/reconciler.go | 2 +- proto/maglev.proto | 5 + 33 files changed, 1729 insertions(+), 241 deletions(-) delete mode 100644 cmd/frontend/web/dist/assets/index-AsNHMKdQ.js create mode 100644 cmd/frontend/web/dist/assets/index-BBNMNdtq.js delete mode 100644 cmd/frontend/web/dist/assets/index-CrBeXDdb.css create mode 100644 cmd/frontend/web/dist/assets/index-CxDuAfMR.css create mode 100644 cmd/frontend/web/src/components/Modal.tsx diff --git a/.gitignore b/.gitignore index 7d3a46a..ce0f71c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ tests/.venv/ tests/**/maglevd.log tests/**/clab-*/ cmd/frontend/web/node_modules/ +# Binaries built at the repo root via `go build ./cmd//` (no -o). +# Anchored with a leading slash so they don't also match the source +# dirs under cmd/. +/frontend +/maglevc +/maglevd diff --git a/cmd/frontend/client.go b/cmd/frontend/client.go index 44c15a7..b29529d 100644 --- a/cmd/frontend/client.go +++ b/cmd/frontend/client.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "net" + "sort" "strings" "sync" "time" @@ -125,6 +126,23 @@ func (c *maglevClient) BackendAction(ctx context.Context, name, action string) ( return backendFromProto(bi), nil } +// SetBackendWeight runs the SetFrontendPoolBackendWeight gRPC call. A +// fresh FrontendSnapshot is returned so admin callers get the +// post-mutation effective weights in one round-trip. +func (c *maglevClient) SetBackendWeight(ctx context.Context, frontend, pool, backend string, weight int32, flush bool) (*FrontendSnapshot, error) { + fi, err := c.api.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{ + Frontend: frontend, + Pool: pool, + Backend: backend, + Weight: weight, + Flush: flush, + }) + if err != nil { + return nil, err + } + return frontendFromProto(fi), nil +} + func (c *maglevClient) Start(ctx context.Context) { go c.watchLoop(ctx) go c.refreshLoop(ctx) @@ -209,7 +227,12 @@ func (c *maglevClient) refreshAll(ctx context.Context) error { if err != nil { return fmt.Errorf("list frontends: %w", err) } + // Sort alphabetically so the UI layout is stable across + // reloads/restarts. maglevd's checker.ListFrontends already sorts + // in current versions, but older builds don't — sort here too as + // a belt-and-braces guarantee. frontendsOrder := append([]string(nil), fl.GetFrontendNames()...) + sort.Strings(frontendsOrder) for _, name := range frontendsOrder { fi, err := c.api.GetFrontend(rctx, &grpcapi.GetFrontendRequest{Name: name}) if err != nil { @@ -360,6 +383,18 @@ func (c *maglevClient) handleEvent(ev *grpcapi.Event) { attrs[a.GetKey()] = a.GetValue() } c.applyVPPLogHeartbeat(le.GetMsg()) + // A config reload on maglevd can shuffle anything: add or + // remove frontends, change pool membership, flip configured + // weights, move backends between pools. Rather than try to + // incrementally update the cache for every possible change, + // refresh the whole maglevd state and tell every connected + // browser to re-hydrate from the fresh snapshot. Only the + // "-done" event triggers this, not "-start": a failed reload + // (which never emits "-done") leaves the running config + // unchanged, so no refresh is needed. + if le.GetMsg() == "config-reload-done" { + c.triggerConfigResync() + } payload, _ := json.Marshal(LogEventPayload{ Level: le.GetLevel(), Msg: le.GetMsg(), @@ -428,6 +463,43 @@ func (c *maglevClient) handleEvent(ev *grpcapi.Event) { } } +// triggerConfigResync runs refreshAll off the event-dispatch goroutine +// (so the stream.Recv loop isn't blocked while the full config refetch +// hits several gRPC calls) and then publishes a BrowserEvent of type +// "resync" so every connected browser re-fetches /view/api/state from +// the now-fresh cache. Fired in response to a maglevd "config-reload- +// done" log event. +// +// The refresh-then-publish order matters: if we published first, the +// SPA would fetchState from a stale cache and display old data until +// the next 30s refresh tick. Running refreshAll synchronously inside +// this goroutine closes that window. +// +// The resync event goes through the normal broker → ring buffer path, +// so a browser that reconnects shortly after the reload (within the +// 30s / 2000-event replay window) still sees the resync on its first +// live event and re-hydrates without needing a separate out-of-band +// signal. +func (c *maglevClient) triggerConfigResync() { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := c.refreshAll(ctx); err != nil { + slog.Warn("config-resync-refresh", "maglevd", c.name, "err", err) + // Publish anyway — the SPA's refetch will see the + // cache in whatever state refreshAll left it, and + // the periodic refreshLoop will retry. Better than + // silently dropping the signal. + } + c.broker.Publish(BrowserEvent{ + Maglevd: c.name, + Type: "resync", + AtUnixNs: time.Now().UnixNano(), + Payload: json.RawMessage("{}"), + }) + }() +} + // applyFrontendState writes the given state into the cached frontend // snapshot. Called both by synthetic replay events on subscribe and by // live transitions afterwards. @@ -479,11 +551,25 @@ func (c *maglevClient) applyBackendTransition(name string, tr *TransitionRecord) defer c.mu.Unlock() b, ok := c.cache.Backends[name] if !ok { + // Partial-create fallback for a transition that arrives before + // the first refreshAll has seen this backend. The real fields + // (address, healthcheck, pool memberships) are filled in on + // the next refresh tick; here we just stamp Name so the entry + // exists. b = &BackendSnapshot{Name: name} c.cache.Backends[name] = b c.cache.BackendsOrder = append(c.cache.BackendsOrder, name) } b.State = tr.To + // Derive Enabled from State. In maglevd, state="disabled" and + // config.enabled=false are two ways of expressing the same + // condition — DisableBackend / EnableBackend flip both together, + // and no other state corresponds to enabled=false. Keeping them + // in sync in the reducer closes a race where the cache's cached + // Enabled could lag behind state by up to a refreshLoop tick, + // causing the SPA to render a bogus [disabled] tag next to an + // "up" badge on a freshly-re-enabled backend. + b.Enabled = tr.To != "disabled" b.LastTransition = tr b.Transitions = append(b.Transitions, tr) // Cap history to the most recent 20 entries to mirror what maglevd @@ -566,8 +652,16 @@ func backendFromProto(bi *grpcapi.BackendInfo) *BackendSnapshot { Enabled: bi.GetEnabled(), HealthCheck: bi.GetHealthcheck(), } - for _, t := range bi.GetTransitions() { - out.Transitions = append(out.Transitions, transitionFromProto(t)) + // maglevd stores and returns transitions newest-first (it prepends + // in health.Backend.transition()). The client stores them + // oldest-first so applyBackendTransition can simply append new + // events to the end. Reverse on read to reconcile the two + // conventions — then out.Transitions[n-1] is the newest, which is + // the correct LastTransition. + trs := bi.GetTransitions() + out.Transitions = make([]*TransitionRecord, len(trs)) + for i, t := range trs { + out.Transitions[len(trs)-1-i] = transitionFromProto(t) } if n := len(out.Transitions); n > 0 { out.LastTransition = out.Transitions[n-1] diff --git a/cmd/frontend/handlers.go b/cmd/frontend/handlers.go index b4149f3..62cd066 100644 --- a/cmd/frontend/handlers.go +++ b/cmd/frontend/handlers.go @@ -124,17 +124,21 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke // handleAdminAPI dispatches mutation requests under /admin/api/. // -// Currently the only supported shape is: +// Supported shapes: // // POST /admin/api/{maglevd}/backend/{name}/{pause|resume|enable|disable} +// → fresh BackendSnapshot as JSON // -// The response body is the fresh BackendSnapshot (JSON) returned by -// maglevd. The WatchEvents stream also delivers a transition event -// so every connected browser converges through the normal reducer -// path — the POST response is primarily for the originating SPA to -// learn about failures immediately. Errors from the gRPC side are -// surfaced as 400 (bad request / unknown action / unknown target) -// or 502 (maglevd returned an error). +// POST /admin/api/{maglevd}/frontend/{fe}/pool/{pool}/backend/{name}/weight +// body: {"weight": 0-100, "flush": bool} +// → fresh FrontendSnapshot as JSON +// +// The WatchEvents stream also delivers a backend-transition (and, for +// the weight case, no event — since the config mutation doesn't flip +// the health state). The POST response is primarily for the +// originating SPA to learn about failures and to refresh effective +// weights immediately. Errors from the gRPC side are surfaced as +// 400 (bad request) or 502 (maglevd returned an error). func handleAdminAPI(w http.ResponseWriter, r *http.Request, byName map[string]*maglevClient) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") @@ -142,17 +146,32 @@ func handleAdminAPI(w http.ResponseWriter, r *http.Request, byName map[string]*m return } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/admin/api/"), "/") - // Expect: {maglevd} "backend" {name} {action} - if len(parts) != 4 || parts[1] != "backend" { + // Peel off the maglevd name (always the first segment). + if len(parts) < 2 { http.NotFound(w, r) return } - maglevd, name, action := parts[0], parts[2], parts[3] + maglevd := parts[0] c, ok := byName[maglevd] if !ok { http.NotFound(w, r) return } + rest := parts[1:] + + switch { + // {maglevd}/backend/{name}/{action} + case len(rest) == 3 && rest[0] == "backend": + handleBackendLifecycle(w, r, c, rest[1], rest[2]) + // {maglevd}/frontend/{fe}/pool/{pool}/backend/{name}/weight + case len(rest) == 7 && rest[0] == "frontend" && rest[2] == "pool" && rest[4] == "backend" && rest[6] == "weight": + handleBackendWeight(w, r, c, rest[1], rest[3], rest[5]) + default: + http.NotFound(w, r) + } +} + +func handleBackendLifecycle(w http.ResponseWriter, r *http.Request, c *maglevClient, name, action string) { switch action { case "pause", "resume", "enable", "disable": default: @@ -163,12 +182,43 @@ func handleAdminAPI(w http.ResponseWriter, r *http.Request, byName map[string]*m defer cancel() snap, err := c.BackendAction(ctx, name, action) if err != nil { - slog.Warn("admin-backend-action", "maglevd", maglevd, "backend", name, "action", action, "err", err) + slog.Warn("admin-backend-action", "maglevd", c.name, "backend", name, "action", action, "err", err) http.Error(w, err.Error(), http.StatusBadGateway) return } slog.Info("admin-backend-action", - "maglevd", maglevd, "backend", name, "action", action, "state", snap.State) + "maglevd", c.name, "backend", name, "action", action, "state", snap.State) + writeJSON(w, snap) +} + +type setWeightBody struct { + Weight int32 `json:"weight"` + Flush bool `json:"flush"` +} + +func handleBackendWeight(w http.ResponseWriter, r *http.Request, c *maglevClient, frontend, pool, backend string) { + var body setWeightBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, fmt.Sprintf("bad json: %v", err), http.StatusBadRequest) + return + } + if body.Weight < 0 || body.Weight > 100 { + http.Error(w, fmt.Sprintf("weight %d out of range [0, 100]", body.Weight), http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + snap, err := c.SetBackendWeight(ctx, frontend, pool, backend, body.Weight, body.Flush) + if err != nil { + slog.Warn("admin-set-weight", + "maglevd", c.name, "frontend", frontend, "pool", pool, "backend", backend, + "weight", body.Weight, "flush", body.Flush, "err", err) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + slog.Info("admin-set-weight", + "maglevd", c.name, "frontend", frontend, "pool", pool, "backend", backend, + "weight", body.Weight, "flush", body.Flush) writeJSON(w, snap) } @@ -264,6 +314,17 @@ func serveSSE(w http.ResponseWriter, r *http.Request, broker *Broker) { } func writeEvent(w http.ResponseWriter, ev deliveredEvent) error { + // "resync" goes out as a named SSE event so the SPA's existing + // addEventListener("resync", ...) handler fires (and not the + // default onmessage path). Every other event type keeps the + // default onmessage path with a JSON body. We still emit an id + // so a reconnecting browser can replay from the right point in + // the ring; the resync handler is idempotent (a duplicate + // replay just triggers a redundant fetchState). + if ev.Event.Type == "resync" { + _, err := fmt.Fprintf(w, "id: %s\nevent: resync\ndata: {}\n\n", ev.ID) + return err + } body, err := json.Marshal(ev.Event) if err != nil { return err diff --git a/cmd/frontend/web/dist/assets/index-AsNHMKdQ.js b/cmd/frontend/web/dist/assets/index-AsNHMKdQ.js deleted file mode 100644 index 453dc5a..0000000 --- a/cmd/frontend/web/dist/assets/index-AsNHMKdQ.js +++ /dev/null @@ -1 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))r(s);new MutationObserver(s=>{for(const l of s)if(l.type==="childList")for(const i of l.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&r(i)}).observe(document,{childList:!0,subtree:!0});function n(s){const l={};return s.integrity&&(l.integrity=s.integrity),s.referrerPolicy&&(l.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?l.credentials="include":s.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function r(s){if(s.ep)return;s.ep=!0;const l=n(s);fetch(s.href,l)}})();const Je=!1,Xe=(e,t)=>e===t,I=Symbol("solid-proxy"),pe=Symbol("solid-track"),re={equals:Xe};let Ce=Te;const D=1,se=2,Oe={owned:null,cleanups:null,context:null,owner:null};var v=null;let he=null,ze=null,y=null,w=null,N=null,de=0;function ne(e,t){const n=y,r=v,s=e.length===0,l=t===void 0?r:t,i=s?Oe:{owned:null,cleanups:null,context:l?l.context:null,owner:l},o=s?e:()=>e(()=>L(()=>z(i)));v=i,y=null;try{return q(o,!0)}finally{y=n,v=r}}function _(e,t){t=t?Object.assign({},re,t):re;const n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},r=s=>(typeof s=="function"&&(s=s(n.value)),Le(n,s));return[Ne.bind(n),r]}function S(e,t,n){const r=ve(e,t,!1,D);Z(r)}function Y(e,t,n){Ce=nt;const r=ve(e,t,!1,D);r.user=!0,N?N.push(r):Z(r)}function R(e,t,n){n=n?Object.assign({},re,n):re;const r=ve(e,t,!0,0);return r.observers=null,r.observerSlots=null,r.comparator=n.equals||void 0,Z(r),Ne.bind(r)}function Qe(e){return q(e,!1)}function L(e){if(y===null)return e();const t=y;y=null;try{return e()}finally{y=t}}function Ye(e,t,n){const r=Array.isArray(e);let s,l=n&&n.defer;return i=>{let o;if(r){o=Array(e.length);for(let f=0;ft(o,s,i));return s=o,a}}function Ze(e){Y(()=>L(e))}function Pe(e){return v===null||(v.cleanups===null?v.cleanups=[e]:v.cleanups.push(e)),e}function $e(){return y}function Ne(){if(this.sources&&this.state)if(this.state===D)Z(this);else{const e=w;w=null,q(()=>ie(this),!1),w=e}if(y){const e=this.observers?this.observers.length:0;y.sources?(y.sources.push(this),y.sourceSlots.push(e)):(y.sources=[this],y.sourceSlots=[e]),this.observers?(this.observers.push(y),this.observerSlots.push(y.sources.length-1)):(this.observers=[y],this.observerSlots=[y.sources.length-1])}return this.value}function Le(e,t,n){let r=e.value;return(!e.comparator||!e.comparator(r,t))&&(e.value=t,e.observers&&e.observers.length&&q(()=>{for(let s=0;s1e6)throw w=[],new Error},!1)),t}function Z(e){if(!e.fn)return;z(e);const t=de;et(e,e.value,t)}function et(e,t,n){let r;const s=v,l=y;y=v=e;try{r=e.fn(t)}catch(i){return e.pure&&(e.state=D,e.owned&&e.owned.forEach(z),e.owned=null),e.updatedAt=n+1,Be(i)}finally{y=l,v=s}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?Le(e,r):e.value=r,e.updatedAt=n)}function ve(e,t,n,r=D,s){const l={fn:e,state:r,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:v,context:v?v.context:null,pure:n};return v===null||v!==Oe&&(v.owned?v.owned.push(l):v.owned=[l]),l}function le(e){if(e.state===0)return;if(e.state===se)return ie(e);if(e.suspense&&L(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;n--)if(e=t[n],e.state===D)Z(e);else if(e.state===se){const r=w;w=null,q(()=>ie(e,t[0]),!1),w=r}}function q(e,t){if(w)return e();let n=!1;t||(w=[]),N?n=!0:N=[],de++;try{const r=e();return tt(n),r}catch(r){n||(N=null),w=null,Be(r)}}function tt(e){if(w&&(Te(w),w=null),e)return;const t=N;N=null,t.length&&q(()=>Ce(t),!1)}function Te(e){for(let t=0;t=0;t--)z(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)z(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function rt(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function Be(e,t=v){throw rt(e)}const st=Symbol("fallback");function ke(e){for(let t=0;t1?[]:null;return Pe(()=>ke(l)),()=>{let a=e()||[],f=a.length,u,c;return a[pe],L(()=>{let p,$,m,C,M,k,A,x,T;if(f===0)i!==0&&(ke(l),l=[],r=[],s=[],i=0,o&&(o=[])),n.fallback&&(r=[st],s[0]=ne(te=>(l[0]=te,n.fallback())),i=1);else if(i===0){for(s=new Array(f),c=0;c=k&&x>=k&&r[A]===a[x];A--,x--)m[x]=s[A],C[x]=l[A],o&&(M[x]=o[A]);for(p=new Map,$=new Array(x+1),c=x;c>=k;c--)T=a[c],u=p.get(T),$[c]=u===void 0?-1:u,p.set(T,c);for(u=k;u<=A;u++)T=r[u],c=p.get(T),c!==void 0&&c!==-1?(m[c]=s[u],C[c]=l[u],o&&(M[c]=o[u]),c=$[c],p.set(T,c)):l[u]();for(c=k;ce(t||{}))}const it=e=>`Stale read from <${e}>.`;function V(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(lt(()=>e.each,e.children,t||void 0))}function E(e){const t=e.keyed,n=R(()=>e.when,void 0,void 0),r=t?n:R(n,void 0,{equals:(s,l)=>!s==!l});return R(()=>{const s=r();if(s){const l=e.children;return typeof l=="function"&&l.length>0?L(()=>l(t?s:()=>{if(!L(r))throw it("Show");return n()})):l}return e.fallback},void 0,void 0)}const B=e=>R(()=>e());function ot(e,t,n){let r=n.length,s=t.length,l=r,i=0,o=0,a=t[s-1].nextSibling,f=null;for(;iu-o){const $=t[i];for(;o{s=l,t===document?e():d(t,e(),t.firstChild?null:void 0,n)},r.owner),()=>{s(),t.textContent=""}}function h(e,t,n,r){let s;const l=()=>{const o=document.createElement("template");return o.innerHTML=e,o.content.firstChild},i=()=>(s||(s=l())).cloneNode(!0);return i.cloneNode=i,i}function we(e,t=window.document){const n=t[Ae]||(t[Ae]=new Set);for(let r=0,s=e.length;re(t,n))}function d(e,t,n,r){if(n!==void 0&&!r&&(r=[]),typeof t!="function")return oe(e,t,r,n);S(s=>oe(e,t(),s,n),r)}function ut(e){let t=e.target;const n=`$$${e.type}`,r=e.target,s=e.currentTarget,l=a=>Object.defineProperty(e,"target",{configurable:!0,value:a}),i=()=>{const a=t[n];if(a&&!t.disabled){const f=t[`${n}Data`];if(f!==void 0?a.call(t,f,e):a.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&l(t.host),!0},o=()=>{for(;i()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const a=e.composedPath();l(a[0]);for(let f=0;f{let o=t();for(;typeof o=="function";)o=o();n=oe(e,o,n,r)}),()=>n;if(Array.isArray(t)){const o=[],a=n&&Array.isArray(n);if(me(o,t,n,s))return S(()=>n=oe(e,o,n,r,!0)),()=>n;if(o.length===0){if(n=F(e,n,r),i)return n}else a?n.length===0?xe(e,o,r):ot(e,n,o):(n&&F(e),xe(e,o));n=o}else if(t.nodeType){if(Array.isArray(n)){if(i)return n=F(e,n,r,t);F(e,n,null,t)}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t}}return n}function me(e,t,n,r){let s=!1;for(let l=0,i=t.length;l=0;i--){const o=t[i];if(s!==o){const a=o.parentNode===e;!l&&!i?a?e.replaceChild(s,o):e.insertBefore(s,n):a&&o.remove()}else l=!0}}else e.insertBefore(s,n);return[s]}async function De(e){const t=await fetch(e,{credentials:"same-origin"});if(!t.ok)throw new Error(`${e}: ${t.status} ${t.statusText}`);return await t.json()}function Ie(){return De("/view/api/state")}function ft(){return De("/view/api/version")}const ae=Symbol("store-raw"),U=Symbol("store-node"),O=Symbol("store-has"),Me=Symbol("store-self");function Fe(e){let t=e[I];if(!t&&(Object.defineProperty(e,I,{value:t=new Proxy(e,ht)}),!Array.isArray(e))){const n=Object.keys(e),r=Object.getOwnPropertyDescriptors(e);for(let s=0,l=n.length;se[I][t]),n}function Re(e){$e()&&Q(ce(e,U),Me)()}function gt(e){return Re(e),Reflect.ownKeys(e)}const ht={get(e,t,n){if(t===ae)return e;if(t===I)return n;if(t===pe)return Re(e),n;const r=ce(e,U),s=r[t];let l=s?s():e[t];if(t===U||t===O||t==="__proto__")return l;if(!s){const i=Object.getOwnPropertyDescriptor(e,t);$e()&&(typeof l!="function"||e.hasOwnProperty(t))&&!(i&&i.get)&&(l=Q(r,t,l)())}return W(l)?Fe(l):l},has(e,t){return t===ae||t===I||t===pe||t===U||t===O||t==="__proto__"?!0:($e()&&Q(ce(e,O),t)(),t in e)},set(){return!0},deleteProperty(){return!0},ownKeys:gt,getOwnPropertyDescriptor:dt};function H(e,t,n,r=!1){if(!r&&e[t]===n)return;const s=e[t],l=e.length;n===void 0?(delete e[t],e[O]&&e[O][t]&&s!==void 0&&e[O][t].$()):(e[t]=n,e[O]&&e[O][t]&&s===void 0&&e[O][t].$());let i=ce(e,U),o;if((o=Q(i,t,s))&&o.$(()=>n),Array.isArray(e)&&e.length!==l){for(let a=e.length;a1){r=t.shift();const i=typeof r,o=Array.isArray(e);if(Array.isArray(r)){for(let a=0;a1){J(e[r],t,[r].concat(n));return}s=e[r],n=[r].concat(n)}let l=t[0];typeof l=="function"&&(l=l(s,n),l===s)||r===void 0&&l==null||(l=K(l),r===void 0||W(s)&&W(l)&&!Array.isArray(l)?Ue(s,l):H(e,r,l))}function pt(...[e,t]){const n=K(e||{}),r=Array.isArray(n),s=Fe(n);function l(...i){Qe(()=>{r&&i.length===1?bt(n,i[0]):J(n,i)})}return[s,l]}const ue=new WeakMap,Ve={get(e,t){if(t===ae)return e;const n=e[t];let r;return W(n)?ue.get(n)||(ue.set(n,r=new Proxy(n,Ve)),r):n},set(e,t,n){return H(e,t,K(n)),!0},deleteProperty(e,t){return H(e,t,void 0,!0),!0}};function ee(e){return t=>{if(W(t)){let n;(n=ue.get(t))||ue.set(t,n=new Proxy(t,Ve)),e(n)}return t}}const[$t,mt]=_(0);setInterval(()=>mt(e=>e+1),5e3);const[fe,G]=pt({byName:{}});function We(e){const t={};for(const n of e)t[n.maglevd.name]=n;G({byName:t})}function yt(e,t){G(ee(n=>{const r=n.byName[e];if(!r)return;const s=r.backends.find(l=>l.name===t.backend);s&&(s.state=t.transition.to,s.last_transition=t.transition,s.transitions||(s.transitions=[]),s.transitions.push(t.transition),s.transitions.length>20&&(s.transitions=s.transitions.slice(s.transitions.length-20)))}))}function vt(e,t){G(ee(n=>{const r=n.byName[e];if(!r)return;const s=r.frontends.find(l=>l.name===t.frontend);s&&(s.state=t.transition.to)}))}function wt(e,t){G(ee(n=>{const r=n.byName[e];r&&(r.vpp_state=t)}))}function _t(e,t){G(ee(n=>{const r=n.byName[e];r&&(r.maglevd.connected=t.connected,r.maglevd.last_error=t.last_error)}))}function be(e,t,n){G(ee(r=>{const s=r.byName[e];if(!s)return;const l=s.backends.find(i=>i.address===t);if(l)for(const i of s.frontends)for(const o of i.pools)for(const a of o.backends)a.name===l.name&&(a.effective_weight=n)}))}function St(e){if($t(),!e||!e.at_unix_ns||e.at_unix_ns<=0)return"";const t=Date.now()-e.at_unix_ns/1e6,n=Math.floor(t/1e3);if(n<=1)return"now";const r=n%60,s=Math.floor(n/60);if(s<1)return`${n}s ago`;const l=s%60,i=Math.floor(s/60);if(i<1)return`${l}m${r}s ago`;const o=i%24,a=Math.floor(i/24);return a<1?`${i}h${l}m ago`:`${a}d${o}h ago`}const Ee=500,[ye,kt]=_([]);function At(e){kt(t=>{const n=[...t,e];return n.length>Ee?n.slice(n.length-Ee):n})}function xt(){const e=new EventSource("/view/api/events");return e.onmessage=t=>{try{const n=JSON.parse(t.data);Et(n)}catch(n){console.error("sse parse error",n,t.data)}},e.addEventListener("resync",async()=>{try{const t=await Ie();We(t)}catch(t){console.error("resync refetch failed",t)}}),e.onerror=t=>{console.debug("sse error, browser will reconnect",t)},e}function Et(e){switch(At(e),e.type){case"backend":yt(e.maglevd,e.payload);break;case"frontend":vt(e.maglevd,e.payload);break;case"maglevd-status":_t(e.maglevd,e.payload);break;case"vpp-status":wt(e.maglevd,e.payload.state);break;case"log":Ct(e.maglevd,e.payload);break}}function Ct(e,t){if(!t.msg.startsWith("vpp-lb-sync-as-"))return;const n=t.attrs??{},r=n.address;if(r)switch(t.msg){case"vpp-lb-sync-as-added":be(e,r,Number(n.weight??0));break;case"vpp-lb-sync-as-removed":be(e,r,0);break;case"vpp-lb-sync-as-weight-updated":be(e,r,Number(n.to??0));break}}const[ge,Ke]=_(void 0);var Ot=h("