From 1a1c48ef54e4bad7980dfd55accf92123207a49c Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Mon, 13 Apr 2026 14:23:34 +0200 Subject: [PATCH] LB buckets column + health cascade; VPP dump fix; maglevc strictness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPA (cmd/frontend/web): - New "lb buckets" column backed by a 1s-debounced GetVPPLBState fetch loop with leading+trailing edge coalesce. - Per-frontend health icon (✅/⚠️/❗/‼️/❓) in the Zippy header, gated by a settling flag that suppresses ‼️ until the next lb-state reconciliation after a backend transition or weight change. - In-place leaf merge on lb-state so stable bucket values (e.g. "0") don't retrigger the Flash animation on every refresh. - Zippy cards remember open state in a cookie, default closed on fresh load; fixed-width frontend-title-name + reserved icon slot so headers line up across all cards. - Clock-drift watchdog in sse.ts that forces a fresh EventSource on laptop-wake so the broker emits a resync instead of hanging on a dead half-open socket. Frontend service (cmd/frontend): - maglevClient.lbStateLoop, trigger on backend transitions + vpp-connect, best-effort fetch on refreshAll. - Admin handlers explicitly wake the lb-state loop after lifecycle ops and set-weight (the latter emits no transition event on the maglevd side, so the WatchEvents path wouldn't have caught it). - /favicon.ico served from embedded web/public IPng logo. VPP integration: - internal/vpp/lbstate.go: dumpASesForVIP drops Pfx from the dump request (setting it silently wipes IPv4 replies in the LB plugin) and filters results by prefix on the response side instead, which also demuxes multi-VIP-on-same-port cases correctly. maglevc: - Walk now returns the unconsumed token tail; dispatch and the question listener reject unknown commands with a targeted error instead of dumping the full command tree prefixed with garbage. - On '?', echo the current line (including the '?') before the help list so the output reads like birdc. Checker / prober: - internal/checker: ±10% jitter on NextInterval so probes across restart don't all fire on the same tick. - internal/prober: HTTP User-Agent now carries the build version and project URL. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/frontend/client.go | 194 +++++++++++++++ cmd/frontend/handlers.go | 32 +++ cmd/frontend/types.go | 22 +- .../web/dist/assets/index-3BvNJ7QB.css | 1 + .../web/dist/assets/index-CExoCDXh.css | 1 - .../web/dist/assets/index-DCJJqBMY.js | 1 + .../web/dist/assets/index-DjixLt11.js | 1 - cmd/frontend/web/dist/favicon.ico | Bin 0 -> 15406 bytes cmd/frontend/web/dist/index.html | 5 +- cmd/frontend/web/index.html | 1 + cmd/frontend/web/public/favicon.ico | Bin 0 -> 15406 bytes cmd/frontend/web/src/api/sse.ts | 80 +++++- cmd/frontend/web/src/components/Zippy.tsx | 13 +- cmd/frontend/web/src/stores/state.ts | 229 +++++++++++++++++- cmd/frontend/web/src/stores/zippy.ts | 55 +++++ cmd/frontend/web/src/styles/theme.css | 21 ++ cmd/frontend/web/src/types.ts | 16 +- cmd/frontend/web/src/views/BackendRow.tsx | 19 +- cmd/frontend/web/src/views/DebugPanel.tsx | 2 +- cmd/frontend/web/src/views/FrontendCard.tsx | 12 +- cmd/frontend/web/src/views/VPPInfoPanel.tsx | 2 +- cmd/maglevc/complete.go | 34 ++- cmd/maglevc/shell.go | 32 ++- cmd/maglevc/tree.go | 23 +- cmd/maglevc/tree_test.go | 48 +++- internal/checker/checker.go | 14 +- internal/prober/http.go | 5 +- internal/vpp/lbstate.go | 22 +- 28 files changed, 828 insertions(+), 57 deletions(-) create mode 100644 cmd/frontend/web/dist/assets/index-3BvNJ7QB.css delete mode 100644 cmd/frontend/web/dist/assets/index-CExoCDXh.css create mode 100644 cmd/frontend/web/dist/assets/index-DCJJqBMY.js delete mode 100644 cmd/frontend/web/dist/assets/index-DjixLt11.js create mode 100644 cmd/frontend/web/dist/favicon.ico create mode 100644 cmd/frontend/web/public/favicon.ico create mode 100644 cmd/frontend/web/src/stores/zippy.ts diff --git a/cmd/frontend/client.go b/cmd/frontend/client.go index b29529d..647fca9 100644 --- a/cmd/frontend/client.go +++ b/cmd/frontend/client.go @@ -33,6 +33,12 @@ type maglevClient struct { connected bool lastErr string cache cachedState + + // lbWakeCh is a buffer-1 trigger channel feeding lbStateLoop. Every + // backend transition (and a few other events) does a non-blocking send + // here; the loop coalesces bursts into at most one GetVPPLBState call + // per second. See lbStateLoop for the leading+trailing-edge debounce. + lbWakeCh chan struct{} } // cachedState is the per-maglevd snapshot served via the REST handlers. @@ -49,6 +55,7 @@ type cachedState struct { HealthCheckOrder []string VPPInfo *VPPInfoSnapshot VPPState string // "", "connected", "disconnected" + LBState *LBStateSnapshot LastRefresh time.Time } @@ -69,6 +76,7 @@ func newMaglevClient(address string, broker *Broker) (*maglevClient, error) { Backends: map[string]*BackendSnapshot{}, HealthChecks: map[string]*HealthCheckSnapshot{}, }, + lbWakeCh: make(chan struct{}, 1), }, nil } @@ -147,6 +155,7 @@ func (c *maglevClient) Start(ctx context.Context) { go c.watchLoop(ctx) go c.refreshLoop(ctx) go c.healthLoop(ctx) + go c.lbStateLoop(ctx) } func (c *maglevClient) setConnected(ok bool, errMsg string) { @@ -196,6 +205,7 @@ func (c *maglevClient) Snapshot() *StateSnapshot { HealthChecks: make([]*HealthCheckSnapshot, 0, len(c.cache.HealthCheckOrder)), VPPInfo: c.cache.VPPInfo, VPPState: c.cache.VPPState, + LBState: c.cache.LBState, } for _, name := range c.cache.FrontendsOrder { if f, ok := c.cache.Frontends[name]; ok { @@ -302,6 +312,11 @@ func (c *maglevClient) refreshAll(ctx context.Context) error { c.cache.VPPState = vppState c.cache.LastRefresh = time.Now() c.mu.Unlock() + // Best-effort LB state pull so /view/api/state served on a fresh + // page load already carries the bucket column. Errors are + // swallowed by fetchLBStateAndPublish (which clears the cache and + // emits an empty event so the SPA renders "—"). + c.fetchLBStateAndPublish(ctx) return nil } @@ -434,6 +449,11 @@ func (c *maglevClient) handleEvent(ev *grpcapi.Event) { AtUnixNs: tr.AtUnixNs, Payload: payload, }) + // A real transition means VPP is about to (or already did) + // reshuffle bucket allocations across the affected VIP. Wake + // the lb-state loop so the SPA's bucket column converges + // without waiting for the 30s refresh. + c.triggerLBStateFetch() case *grpcapi.Event_Frontend: fe := body.Frontend @@ -544,6 +564,11 @@ func (c *maglevClient) applyVPPLogHeartbeat(msg string) { AtUnixNs: time.Now().UnixNano(), Payload: payload, }) + // VPP just came back: pull fresh LB state so the bucket column + // repopulates immediately instead of waiting up to 30s for the + // next refresh tick. On vpp-disconnect the next fetch will fail + // and clear the cache, which is also the right behaviour. + c.triggerLBStateFetch() } func (c *maglevClient) applyBackendTransition(name string, tr *TransitionRecord) { @@ -677,6 +702,175 @@ func transitionFromProto(t *grpcapi.TransitionRecord) *TransitionRecord { } } +// triggerLBStateFetch sends a non-blocking wake to lbStateLoop. The +// channel has buffer 1 so coalesced bursts never block the publisher. +func (c *maglevClient) triggerLBStateFetch() { + select { + case c.lbWakeCh <- struct{}{}: + default: + } +} + +// lbStateLoop consumes wake signals and calls GetVPPLBState, with a +// leading+trailing-edge debounce so we never exceed one fetch per +// minLBInterval (1s). The leading edge means the very first wake after +// an idle period fires immediately — important so a single isolated +// transition isn't artificially delayed by a second. The trailing edge +// means a burst of wakes during the cool-down still gets one final +// fetch right after the gate opens, so the SPA always converges to a +// post-burst snapshot rather than missing the last update. +func (c *maglevClient) lbStateLoop(ctx context.Context) { + const minLBInterval = time.Second + var ( + timer *time.Timer + lastFetch time.Time + ) + timerCh := func() <-chan time.Time { + if timer == nil { + return nil + } + return timer.C + } + fire := func() { + if timer != nil { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer = nil + } + c.fetchLBStateAndPublish(ctx) + lastFetch = time.Now() + } + for { + select { + case <-ctx.Done(): + return + case <-c.lbWakeCh: + wait := minLBInterval - time.Since(lastFetch) + if wait <= 0 { + fire() + } else if timer == nil { + timer = time.NewTimer(wait) + } + case <-timerCh(): + timer = nil + fire() + } + } +} + +// fetchLBStateAndPublish runs one GetVPPLBState round-trip, rebuilds +// the per-frontend bucket map, swaps it into the cache, and broadcasts +// a "lb-state" BrowserEvent. On error the cache is cleared and an +// empty event is published so the SPA can switch the bucket column to +// em-dashes — clear-on-error is simpler than stale-but-visible and +// doesn't risk showing a confusing snapshot from before VPP died. +func (c *maglevClient) fetchLBStateAndPublish(ctx context.Context) { + fctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + lbs, err := c.api.GetVPPLBState(fctx, &grpcapi.GetVPPLBStateRequest{}) + if err != nil { + c.mu.Lock() + had := c.cache.LBState != nil + c.cache.LBState = nil + c.mu.Unlock() + slog.Debug("lb-state-fetch", "maglevd", c.name, "err", err) + if had { + c.publishLBState(nil) + } + return + } + snap := c.buildLBStateSnapshot(lbs) + c.mu.Lock() + c.cache.LBState = snap + c.mu.Unlock() + c.publishLBState(snap.PerFrontend) +} + +func (c *maglevClient) publishLBState(perFrontend map[string]map[string]int32) { + payload, _ := json.Marshal(LBStatePayload{PerFrontend: perFrontend}) + c.broker.Publish(BrowserEvent{ + Maglevd: c.name, + Type: "lb-state", + AtUnixNs: time.Now().UnixNano(), + Payload: payload, + }) +} + +// buildLBStateSnapshot translates a VPP-side state record (keyed by +// CIDR/protocol/port and AS address) into a maglev-side record (keyed +// by frontend name and backend name). Unmatched VIPs and unmatched AS +// addresses are silently skipped — they're benign side effects of a +// transient sync gap or a backend address that's only present in one +// of the two universes. +func (c *maglevClient) buildLBStateSnapshot(lbs *grpcapi.VPPLBState) *LBStateSnapshot { + c.mu.RLock() + feByVIP := make(map[string]string, len(c.cache.Frontends)) + for _, f := range c.cache.Frontends { + feByVIP[lbVIPKey(f.Address, f.Protocol, f.Port)] = f.Name + } + backendByAddr := make(map[string]string, len(c.cache.Backends)) + for _, b := range c.cache.Backends { + backendByAddr[b.Address] = b.Name + } + c.mu.RUnlock() + + out := &LBStateSnapshot{PerFrontend: map[string]map[string]int32{}} + for _, v := range lbs.GetVips() { + feName, ok := feByVIP[lbVIPKey(stripLBHostMask(v.GetPrefix()), lbProtoString(v.GetProtocol()), v.GetPort())] + if !ok { + continue + } + row := out.PerFrontend[feName] + if row == nil { + row = map[string]int32{} + out.PerFrontend[feName] = row + } + for _, as := range v.GetApplicationServers() { + bname, ok := backendByAddr[as.GetAddress()] + if !ok { + continue + } + row[bname] = int32(as.GetNumBuckets()) + } + } + return out +} + +// lbVIPKey is the join key between a maglev FrontendSnapshot and a +// VPP-side VPPLBVIP record. Stripping the mask and lower-casing the +// protocol gives a canonical form that both sides can produce. +func lbVIPKey(addr, proto string, port uint32) string { + return fmt.Sprintf("%s/%s/%d", addr, strings.ToLower(proto), port) +} + +// lbProtoString mirrors maglevc's protoString — kept local to avoid a +// cross-package import for two trivial helpers. +func lbProtoString(p uint32) string { + switch p { + case 6: + return "tcp" + case 17: + return "udp" + case 255: + return "any" + } + return fmt.Sprintf("%d", p) +} + +// stripLBHostMask trims "/32" or "/128" from a VPP host-prefix VIP so +// it can be compared against a maglev FrontendSnapshot.Address (which +// is bare). Other shapes are returned unchanged. +func stripLBHostMask(prefix string) string { + if strings.HasSuffix(prefix, "/32") || strings.HasSuffix(prefix, "/128") { + return prefix[:strings.LastIndexByte(prefix, '/')] + } + return prefix +} + func healthCheckFromProto(h *grpcapi.HealthCheckInfo) *HealthCheckSnapshot { return &HealthCheckSnapshot{ Name: h.GetName(), diff --git a/cmd/frontend/handlers.go b/cmd/frontend/handlers.go index d689c54..1cb8805 100644 --- a/cmd/frontend/handlers.go +++ b/cmd/frontend/handlers.go @@ -37,6 +37,23 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke _, _ = w.Write([]byte("ok\n")) }) + // Favicon served from the same embedded dist tree Vite produced. + // Browsers auto-fetch /favicon.ico from the document root regardless + // of where the SPA itself is mounted, so we register a top-level + // handler in addition to whatever /view/favicon.ico picks up via the + // static file server below. Read once at registration so we don't + // touch the embed.FS on every request, and serve with a long + // max-age since the bytes never change for a given binary. + if favicon, ferr := fs.ReadFile(webFS, "web/dist/favicon.ico"); ferr == nil { + mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/x-icon") + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = w.Write(favicon) + }) + } else { + slog.Warn("favicon-missing", "err", ferr) + } + mux.HandleFunc("/view/api/version", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, VersionInfo{ Version: buildinfo.Version(), @@ -188,6 +205,13 @@ func handleBackendLifecycle(w http.ResponseWriter, r *http.Request, c *maglevCli } slog.Info("admin-backend-action", "maglevd", c.name, "backend", name, "action", action, "state", snap.State) + // The maglevd→watch path will deliver a transition event that + // also wakes the lb-state loop, but firing here too makes the + // admin path self-contained and shaves the worst-case race + // where the SPA is still waiting on the WatchEvents replay + // when the POST response lands. The debouncer coalesces any + // duplicate wake. + c.triggerLBStateFetch() writeJSON(w, snap) } @@ -219,6 +243,14 @@ func handleBackendWeight(w http.ResponseWriter, r *http.Request, c *maglevClient slog.Info("admin-set-weight", "maglevd", c.name, "frontend", frontend, "pool", pool, "backend", backend, "weight", body.Weight, "flush", body.Flush) + // Weight changes never produce a transition event on the maglevd + // side (the backend's state is unchanged), so the WatchEvents + // stream won't wake the lb-state loop for us — without an explicit + // trigger here the SPA's bucket column would stay stale until the + // next 30s refresh tick. SyncLBStateVIP on the maglevd side has + // already pushed the new weights into VPP synchronously, so the + // fetch we kick off will see fresh post-mutation buckets. + c.triggerLBStateFetch() writeJSON(w, snap) } diff --git a/cmd/frontend/types.go b/cmd/frontend/types.go index 965aac2..828cbd8 100644 --- a/cmd/frontend/types.go +++ b/cmd/frontend/types.go @@ -15,6 +15,18 @@ type StateSnapshot struct { // from vpp-connect / vpp-disconnect / vpp-api-{send,recv} log // events and re-seeded on every refreshAll tick. VPPState string `json:"vpp_state,omitempty"` + // LBState is the most recent VPP LB plugin view of buckets-per-backend, + // keyed by frontend name → backend name → bucket count. nil when VPP is + // disconnected or no fetch has succeeded yet. + LBState *LBStateSnapshot `json:"lb_state,omitempty"` +} + +// LBStateSnapshot is a per-(frontend, backend) view of VPP's bucket +// allocation. The frontend collects this with GetVPPLBState and matches +// VPP's VIP records back to maglev frontend/backend names so the SPA +// never has to know about VPP-side prefixes or AS addresses. +type LBStateSnapshot struct { + PerFrontend map[string]map[string]int32 `json:"per_frontend"` } // MaglevdInfo is the per-maglevd connection status record. @@ -98,11 +110,19 @@ type VPPInfoSnapshot struct { // BrowserEvent is the wire shape sent over SSE to the browser. type BrowserEvent struct { Maglevd string `json:"maglevd"` - Type string `json:"type"` // log|backend|frontend|maglevd-status|resync + Type string `json:"type"` // log|backend|frontend|maglevd-status|vpp-status|lb-state|resync AtUnixNs int64 `json:"at_unix_ns"` Payload json.RawMessage `json:"payload"` } +// LBStatePayload rides on a "lb-state" BrowserEvent and carries the +// freshly-fetched bucket map. PerFrontend may be nil (or empty) to +// signal "no LB state available" — the SPA renders such backends +// with an em-dash in the buckets column. +type LBStatePayload struct { + PerFrontend map[string]map[string]int32 `json:"per_frontend"` +} + // BackendEventPayload is what we ship inside BrowserEvent.Payload for // type == "backend". type BackendEventPayload struct { diff --git a/cmd/frontend/web/dist/assets/index-3BvNJ7QB.css b/cmd/frontend/web/dist/assets/index-3BvNJ7QB.css new file mode 100644 index 0000000..1a1fc9f --- /dev/null +++ b/cmd/frontend/web/dist/assets/index-3BvNJ7QB.css @@ -0,0 +1 @@ +*,*:before,*:after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:14px;line-height:1.4;color:var(--fg);background:var(--bg)}h1,h2,h3,h4,p,dl,dd,ol,ul{margin:0;padding:0}ol,ul{list-style:none}a{color:inherit;text-decoration:none}button{font:inherit;color:inherit;background:none;border:1px solid var(--border);border-radius:4px;padding:4px 8px;cursor:pointer}button:hover{background:var(--bg-soft)}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:4px 8px}code,pre,.mono{font-family:SF Mono,Menlo,Consolas,monospace}:root{--bg: #fafafa;--bg-soft: #f0f0f0;--bg-card: #ffffff;--fg: #0f172a;--fg-muted: #6b7280;--border: #e5e7eb;--accent: #2563eb;--state-up: #16a34a;--state-down: #dc2626;--state-paused: #2563eb;--state-disabled: #6b7280;--state-unknown: #eab308;--state-removed: #374151}.flash-target{display:inline-block;padding:0 4px;border-radius:3px;transform-origin:center center;will-change:transform,background-color,box-shadow}.backend-row td{overflow:visible}.app{max-width:1400px;margin:0 auto;padding:16px}.app-header{display:flex;align-items:center;gap:16px;padding:4px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{display:flex;align-items:center;gap:8px}.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}.brand-logo img{height:56px;width:56px;display:block}.brand-logo:hover img{opacity:.8}.app-header .mode-tag{margin-left:auto;padding:2px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;text-transform:uppercase}.brand .version{margin-left:8px;color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;cursor:help}.admin-toggle{padding:4px 10px;border:1px solid var(--border);border-radius:4px;color:var(--accent)}.scope-selector{display:flex;gap:6px;flex-wrap:wrap}.scope-tab{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:20px}.scope-tab.active{background:var(--accent);color:#fff;border-color:var(--accent)}.scope-tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--state-down)}.scope-tab.connected .dot{background:var(--state-up)}.status-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:500;color:#fff;text-transform:capitalize}.status-badge[data-state=up]{background:var(--state-up)}.status-badge[data-state=down]{background:var(--state-down)}.status-badge[data-state=paused]{background:var(--state-paused)}.status-badge[data-state=disabled]{background:var(--state-disabled)}.status-badge[data-state=unknown]{background:var(--state-unknown);color:#1f2937}.status-badge[data-state=removed]{background:var(--state-removed);text-decoration:line-through}.frontend-list{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}.frontend-title{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap}.frontend-title-icon{display:inline-block;width:1.5em;text-align:center;font-size:14px;line-height:1}.frontend-title-name{font-size:15px;font-weight:600;display:inline-block;width:40ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.frontend-title-addr{font-family:SF Mono,Menlo,Consolas,monospace;font-size:12px;color:var(--fg-muted)}.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;padding:1px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;margin-left:4px}.backend-table{table-layout:fixed;width:100%}.backend-table th,.backend-table td{white-space:nowrap}.backend-table th{font-size:11px;color:var(--fg-muted);text-transform:uppercase;border-bottom:1px solid var(--border);padding:2px 8px}.backend-table .numeric{text-align:right}.backend-table .col-pool{width:10ch}.backend-row .col-pool{color:var(--fg-muted);font-weight:500}.backend-row.pool-standby>td:not(.actions){opacity:.35}.backend-table .col-address{width:42ch}.backend-table .col-state{width:90px}.backend-table .col-weight{width:80px}.backend-table .col-effective,.backend-table .col-buckets{width:95px}.backend-table .col-age{width:110px}.backend-row td.backend-name{overflow:hidden}.backend-name-text{display:inline-block;max-width:calc(100% - 22px);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}.backend-row .backend-address,.backend-row .age{color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:12px}.backend-table th.actions,.backend-row td.actions{width:28px;padding:0 4px;text-align:center}.kebab-wrap{position:relative;display:inline-block}.kebab-btn{width:20px;height:20px;padding:0;line-height:1;font-size:16px;color:var(--fg-muted);border:1px solid transparent;background:transparent;cursor:pointer;border-radius:3px}.kebab-btn:hover,.kebab-btn[aria-expanded=true]{color:var(--fg);background:var(--bg-soft);border-color:var(--border)}.kebab-menu{position:absolute;top:22px;right:0;z-index:20;min-width:120px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;box-shadow:0 4px 12px #0000001f;padding:4px 0}.kebab-item{display:block;width:100%;padding:6px 12px;border:none;background:transparent;text-align:left;cursor:pointer;font-size:13px;color:var(--fg)}.kebab-item:hover{background:var(--bg-soft)}.kebab-item:disabled{color:var(--fg-muted);cursor:wait}.kebab-error{padding:4px 12px 8px;color:var(--state-down);font-size:11px;font-family:SF Mono,Menlo,Consolas,monospace;max-width:260px;white-space:normal}.modal-backdrop{position:fixed;inset:0;background:#0f172a73;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 #00000040;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}.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:#fff;border-color:var(--accent)}.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:.6;cursor:wait}.probe-heartbeat{display:inline-block;width:16px;height:14px;line-height:14px;margin-right:6px;text-align:center;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}.banner{padding:8px 12px;border-radius:4px;margin-bottom:12px;font-size:13px}.banner.warn{background:#fef3c7;color:#92400e}.banner.err{background:#fee2e2;color:#991b1b}.loading,.empty{color:var(--fg-muted);padding:16px}.zippy{border:1px solid var(--border);border-radius:6px;background:var(--bg-card)}.zippy summary{padding:4px 12px;cursor:pointer;font-weight:500}.zippy-body{padding:4px 10px 6px;border-top:1px solid var(--border)}.zippy-title{display:inline-flex;align-items:center;gap:10px}.vpp-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;text-transform:lowercase;color:#fff}.vpp-badge[data-state=connected]{background:var(--state-up)}.vpp-badge[data-state=disconnected]{background:var(--state-down)}.vpp-io{display:inline-flex;align-items:center;gap:0;margin-left:4px;font-size:13px;font-weight:600;line-height:1;letter-spacing:-.15em;padding-right:.15em}.vpp-io-label{color:var(--fg-muted);font-weight:500;letter-spacing:normal;margin-right:4px}.vpp-tx,.vpp-rx{display:inline-block;color:var(--fg-muted);opacity:.25;transition:color .12s ease-out,opacity .12s ease-out,transform .12s ease-out;transform:translateY(0)}.vpp-tx.lit{color:var(--accent);opacity:1;transform:translateY(-1px)}.vpp-rx.lit{color:var(--accent);opacity:1;transform:translateY(1px)}.kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px}.kv dt{color:var(--fg-muted)}.debug-toolbar{display:flex;gap:12px;align-items:center;margin-top:8px;font-size:12px}.debug-toolbar .count{margin-left:auto;color:var(--fg-muted)}.event-tail{max-height:320px;overflow:auto;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;line-height:1.5}.event-row{padding:2px 4px;white-space:pre-wrap;word-break:break-all}.event-row.event-backend{color:var(--state-up)}.event-row.event-frontend{color:var(--accent)}.event-row.event-log{color:var(--fg-muted)}.event-row.event-maglevd-status{color:var(--state-down)}.event-row.event-sync{color:var(--state-paused);font-weight:500} diff --git a/cmd/frontend/web/dist/assets/index-CExoCDXh.css b/cmd/frontend/web/dist/assets/index-CExoCDXh.css deleted file mode 100644 index a835d5c..0000000 --- a/cmd/frontend/web/dist/assets/index-CExoCDXh.css +++ /dev/null @@ -1 +0,0 @@ -*,*:before,*:after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:14px;line-height:1.4;color:var(--fg);background:var(--bg)}h1,h2,h3,h4,p,dl,dd,ol,ul{margin:0;padding:0}ol,ul{list-style:none}a{color:inherit;text-decoration:none}button{font:inherit;color:inherit;background:none;border:1px solid var(--border);border-radius:4px;padding:4px 8px;cursor:pointer}button:hover{background:var(--bg-soft)}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:4px 8px}code,pre,.mono{font-family:SF Mono,Menlo,Consolas,monospace}:root{--bg: #fafafa;--bg-soft: #f0f0f0;--bg-card: #ffffff;--fg: #0f172a;--fg-muted: #6b7280;--border: #e5e7eb;--accent: #2563eb;--state-up: #16a34a;--state-down: #dc2626;--state-paused: #2563eb;--state-disabled: #6b7280;--state-unknown: #eab308;--state-removed: #374151}.flash-target{display:inline-block;padding:0 4px;border-radius:3px;transform-origin:center center;will-change:transform,background-color,box-shadow}.backend-row td{overflow:visible}.app{max-width:1400px;margin:0 auto;padding:16px}.app-header{display:flex;align-items:center;gap:16px;padding:4px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{display:flex;align-items:center;gap:8px}.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}.brand-logo img{height:56px;width:56px;display:block}.brand-logo:hover img{opacity:.8}.app-header .mode-tag{margin-left:auto;padding:2px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;text-transform:uppercase}.brand .version{margin-left:8px;color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;cursor:help}.admin-toggle{padding:4px 10px;border:1px solid var(--border);border-radius:4px;color:var(--accent)}.scope-selector{display:flex;gap:6px;flex-wrap:wrap}.scope-tab{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:20px}.scope-tab.active{background:var(--accent);color:#fff;border-color:var(--accent)}.scope-tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--state-down)}.scope-tab.connected .dot{background:var(--state-up)}.status-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:500;color:#fff;text-transform:capitalize}.status-badge[data-state=up]{background:var(--state-up)}.status-badge[data-state=down]{background:var(--state-down)}.status-badge[data-state=paused]{background:var(--state-paused)}.status-badge[data-state=disabled]{background:var(--state-disabled)}.status-badge[data-state=unknown]{background:var(--state-unknown);color:#1f2937}.status-badge[data-state=removed]{background:var(--state-removed);text-decoration:line-through}.frontend-list{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}.frontend-title{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap}.frontend-title-name{font-size:15px;font-weight:600}.frontend-title-addr{font-family:SF Mono,Menlo,Consolas,monospace;font-size:12px;color:var(--fg-muted)}.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;padding:1px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;margin-left:4px}.backend-table{table-layout:fixed;width:100%}.backend-table th,.backend-table td{white-space:nowrap}.backend-table th{font-size:11px;color:var(--fg-muted);text-transform:uppercase;border-bottom:1px solid var(--border);padding:2px 8px}.backend-table .numeric{text-align:right}.backend-table .col-pool{width:10ch}.backend-row .col-pool{color:var(--fg-muted);font-weight:500}.backend-row.pool-standby>td:not(.actions){opacity:.35}.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}.backend-row td.backend-name{overflow:hidden}.backend-name-text{display:inline-block;max-width:calc(100% - 22px);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}.backend-row .backend-address,.backend-row .age{color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:12px}.backend-table th.actions,.backend-row td.actions{width:28px;padding:0 4px;text-align:center}.kebab-wrap{position:relative;display:inline-block}.kebab-btn{width:20px;height:20px;padding:0;line-height:1;font-size:16px;color:var(--fg-muted);border:1px solid transparent;background:transparent;cursor:pointer;border-radius:3px}.kebab-btn:hover,.kebab-btn[aria-expanded=true]{color:var(--fg);background:var(--bg-soft);border-color:var(--border)}.kebab-menu{position:absolute;top:22px;right:0;z-index:20;min-width:120px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;box-shadow:0 4px 12px #0000001f;padding:4px 0}.kebab-item{display:block;width:100%;padding:6px 12px;border:none;background:transparent;text-align:left;cursor:pointer;font-size:13px;color:var(--fg)}.kebab-item:hover{background:var(--bg-soft)}.kebab-item:disabled{color:var(--fg-muted);cursor:wait}.kebab-error{padding:4px 12px 8px;color:var(--state-down);font-size:11px;font-family:SF Mono,Menlo,Consolas,monospace;max-width:260px;white-space:normal}.modal-backdrop{position:fixed;inset:0;background:#0f172a73;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 #00000040;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}.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:#fff;border-color:var(--accent)}.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:.6;cursor:wait}.probe-heartbeat{display:inline-block;width:16px;height:14px;line-height:14px;margin-right:6px;text-align:center;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}.banner{padding:8px 12px;border-radius:4px;margin-bottom:12px;font-size:13px}.banner.warn{background:#fef3c7;color:#92400e}.banner.err{background:#fee2e2;color:#991b1b}.loading,.empty{color:var(--fg-muted);padding:16px}.zippy{border:1px solid var(--border);border-radius:6px;background:var(--bg-card)}.zippy summary{padding:4px 12px;cursor:pointer;font-weight:500}.zippy-body{padding:4px 10px 6px;border-top:1px solid var(--border)}.zippy-title{display:inline-flex;align-items:center;gap:10px}.vpp-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;text-transform:lowercase;color:#fff}.vpp-badge[data-state=connected]{background:var(--state-up)}.vpp-badge[data-state=disconnected]{background:var(--state-down)}.vpp-io{display:inline-flex;align-items:center;gap:0;margin-left:4px;font-size:13px;font-weight:600;line-height:1;letter-spacing:-.15em;padding-right:.15em}.vpp-io-label{color:var(--fg-muted);font-weight:500;letter-spacing:normal;margin-right:4px}.vpp-tx,.vpp-rx{display:inline-block;color:var(--fg-muted);opacity:.25;transition:color .12s ease-out,opacity .12s ease-out,transform .12s ease-out;transform:translateY(0)}.vpp-tx.lit{color:var(--accent);opacity:1;transform:translateY(-1px)}.vpp-rx.lit{color:var(--accent);opacity:1;transform:translateY(1px)}.kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px}.kv dt{color:var(--fg-muted)}.debug-toolbar{display:flex;gap:12px;align-items:center;margin-top:8px;font-size:12px}.debug-toolbar .count{margin-left:auto;color:var(--fg-muted)}.event-tail{max-height:320px;overflow:auto;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;line-height:1.5}.event-row{padding:2px 4px;white-space:pre-wrap;word-break:break-all}.event-row.event-backend{color:var(--state-up)}.event-row.event-frontend{color:var(--accent)}.event-row.event-log{color:var(--fg-muted)}.event-row.event-maglevd-status{color:var(--state-down)}.event-row.event-sync{color:var(--state-paused);font-weight:500} diff --git a/cmd/frontend/web/dist/assets/index-DCJJqBMY.js b/cmd/frontend/web/dist/assets/index-DCJJqBMY.js new file mode 100644 index 0000000..4aa163d --- /dev/null +++ b/cmd/frontend/web/dist/assets/index-DCJJqBMY.js @@ -0,0 +1 @@ +(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 o of l.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).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 dt=!1,gt=(e,t)=>e===t,G=Symbol("solid-proxy"),Ee=Symbol("solid-track"),de={equals:gt};let Ve=Ye;const V=1,ge=2,Ke={owned:null,cleanups:null,context:null,owner:null};var k=null;let Ae=null,ht=null,_=null,O=null,R=null,ye=0;function le(e,t){const n=_,r=k,s=e.length===0,l=t===void 0?r:t,o=s?Ke:{owned:null,cleanups:null,context:l?l.context:null,owner:l},i=s?e:()=>e(()=>B(()=>oe(o)));k=o,_=null;try{return J(i,!0)}finally{_=n,k=r}}function A(e,t){t=t?Object.assign({},de,t):de;const n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},r=s=>(typeof s=="function"&&(s=s(n.value)),qe(n,s));return[Ge.bind(n),r]}function E(e,t,n){const r=Pe(e,t,!1,V);ce(r)}function K(e,t,n){Ve=_t;const r=Pe(e,t,!1,V);(!n||!n.render)&&(r.user=!0),R?R.push(r):ce(r)}function H(e,t,n){n=n?Object.assign({},de,n):de;const r=Pe(e,t,!0,0);return r.observers=null,r.observerSlots=null,r.comparator=n.equals||void 0,ce(r),Ge.bind(r)}function bt(e){return J(e,!1)}function B(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function mt(e,t,n){const r=Array.isArray(e);let s,l=n&&n.defer;return o=>{let i;if(r){i=Array(e.length);for(let c=0;ct(i,s,o));return s=i,a}}function pt(e){K(()=>B(e))}function q(e){return k===null||(k.cleanups===null?k.cleanups=[e]:k.cleanups.push(e)),e}function Ce(){return _}function $t(){return k}function wt(e,t){const n=k,r=_;k=e,_=null;try{return J(t,!0)}catch(s){Ne(s)}finally{k=n,_=r}}function Ge(){if(this.sources&&this.state)if(this.state===V)ce(this);else{const e=O;O=null,J(()=>be(this),!1),O=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,n){let r=e.value;return(!e.comparator||!e.comparator(r,t))&&(e.value=t,e.observers&&e.observers.length&&J(()=>{for(let s=0;s1e6)throw O=[],new Error},!1)),t}function ce(e){if(!e.fn)return;oe(e);const t=ye;vt(e,e.value,t)}function vt(e,t,n){let r;const s=k,l=_;_=k=e;try{r=e.fn(t)}catch(o){return e.pure&&(e.state=V,e.owned&&e.owned.forEach(oe),e.owned=null),e.updatedAt=n+1,Ne(o)}finally{_=l,k=s}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?qe(e,r):e.value=r,e.updatedAt=n)}function Pe(e,t,n,r=V,s){const l={fn:e,state:r,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:k,context:k?k.context:null,pure:n};return k===null||k!==Ke&&(k.owned?k.owned.push(l):k.owned=[l]),l}function he(e){if(e.state===0)return;if(e.state===ge)return be(e);if(e.suspense&&B(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===V)ce(e);else if(e.state===ge){const r=O;O=null,J(()=>be(e,t[0]),!1),O=r}}function J(e,t){if(O)return e();let n=!1;t||(O=[]),R?n=!0:R=[],ye++;try{const r=e();return yt(n),r}catch(r){n||(R=null),O=null,Ne(r)}}function yt(e){if(O&&(Ye(O),O=null),e)return;const t=R;R=null,t.length&&J(()=>Ve(t),!1)}function Ye(e){for(let t=0;t=0;t--)oe(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)oe(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 kt(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function Ne(e,t=k){throw kt(e)}const St=Symbol("fallback");function Be(e){for(let t=0;t1?[]:null;return q(()=>Be(l)),()=>{let a=e()||[],c=a.length,u,f;return a[Ee],B(()=>{let g,v,w,T,C,p,m,y,S;if(c===0)o!==0&&(Be(l),l=[],r=[],s=[],o=0,i&&(i=[])),n.fallback&&(r=[St],s[0]=le(x=>(l[0]=x,n.fallback())),o=1);else if(o===0){for(s=new Array(c),f=0;f=p&&y>=p&&r[m]===a[y];m--,y--)w[y]=s[m],T[y]=l[m],i&&(C[y]=i[m]);for(g=new Map,v=new Array(y+1),f=y;f>=p;f--)S=a[f],u=g.get(S),v[f]=u===void 0?-1:u,g.set(S,f);for(u=p;u<=m;u++)S=r[u],f=g.get(S),f!==void 0&&f!==-1?(w[f]=s[u],T[f]=l[u],i&&(C[f]=i[u]),f=v[f],g.set(S,f)):l[u]();for(f=p;fe(t||{}))}const At=e=>`Stale read from <${e}>.`;function Q(e){const t="fallback"in e&&{fallback:()=>e.fallback};return H(xt(()=>e.each,e.children,t||void 0))}function N(e){const t=e.keyed,n=H(()=>e.when,void 0,void 0),r=t?n:H(n,void 0,{equals:(s,l)=>!s==!l});return H(()=>{const s=r();if(s){const l=e.children;return typeof l=="function"&&l.length>0?B(()=>l(t?s:()=>{if(!B(r))throw At("Show");return n()})):l}return e.fallback},void 0,void 0)}const I=e=>H(()=>e());function Et(e,t,n){let r=n.length,s=t.length,l=r,o=0,i=0,a=t[s-1].nextSibling,c=null;for(;ou-i){const v=t[o];for(;i{s=l,t===document?e():d(t,e(),t.firstChild?null:void 0,n)},r.owner),()=>{s(),t.textContent=""}}function b(e,t,n,r){let s;const l=()=>{const i=document.createElement("template");return i.innerHTML=e,i.content.firstChild},o=()=>(s||(s=l())).cloneNode(!0);return o.cloneNode=o,o}function _e(e,t=window.document){const n=t[We]||(t[We]=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 me(e,t,r,n);E(s=>me(e,t(),s,n),r)}function Pt(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}),o=()=>{const a=t[n];if(a&&!t.disabled){const c=t[`${n}Data`];if(c!==void 0?a.call(t,c,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},i=()=>{for(;o()&&(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 c=0;c{let i=t();for(;typeof i=="function";)i=i();n=me(e,i,n,r)}),()=>n;if(Array.isArray(t)){const i=[],a=n&&Array.isArray(n);if(Oe(i,t,n,s))return E(()=>n=me(e,i,n,r,!0)),()=>n;if(i.length===0){if(n=X(e,n,r),o)return n}else a?n.length===0?Ue(e,i,r):Et(e,n,i):(n&&X(e),Ue(e,i));n=i}else if(t.nodeType){if(Array.isArray(n)){if(o)return n=X(e,n,r,t);X(e,n,null,t)}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t}}return n}function Oe(e,t,n,r){let s=!1;for(let l=0,o=t.length;l=0;o--){const i=t[o];if(s!==i){const a=i.parentNode===e;!l&&!o?a?e.replaceChild(s,i):e.insertBefore(s,n):a&&i.remove()}else l=!0}}else e.insertBefore(s,n);return[s]}const Nt="http://www.w3.org/2000/svg";function Lt(e,t=!1,n=void 0){return t?document.createElementNS(Nt,e):document.createElement(e,{is:n})}function It(e){const{useShadow:t}=e,n=document.createTextNode(""),r=()=>e.mount||document.body,s=$t();let l;return K(()=>{l||(l=wt(s,()=>H(()=>e.children)));const o=r();if(o instanceof HTMLHeadElement){const[i,a]=A(!1),c=()=>a(!0);le(u=>d(o,()=>i()?u():l(),null)),q(c)}else{const i=Lt(e.isSVG?"g":"div",e.isSVG),a=t&&i.attachShadow?i.attachShadow({mode:"open"}):i;Object.defineProperty(i,"_$host",{get(){return n.parentNode},configurable:!0}),d(a,l),o.appendChild(i),e.ref&&e.ref(i),q(()=>o.removeChild(i))}},void 0,{render:!0}),n}async function Xe(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 Ze(){return Xe("/view/api/state")}function jt(){return Xe("/view/api/version")}const pe=Symbol("store-raw"),z=Symbol("store-node"),M=Symbol("store-has"),ze=Symbol("store-self");function Qe(e){let t=e[G];if(!t&&(Object.defineProperty(e,G,{value:t=new Proxy(e,Rt)}),!Array.isArray(e))){const n=Object.keys(e),r=Object.getOwnPropertyDescriptors(e);for(let s=0,l=n.length;se[G][t]),n}function et(e){Ce()&&ae($e(e,z),ze)()}function Mt(e){return et(e),Reflect.ownKeys(e)}const Rt={get(e,t,n){if(t===pe)return e;if(t===G)return n;if(t===Ee)return et(e),n;const r=$e(e,z),s=r[t];let l=s?s():e[t];if(t===z||t===M||t==="__proto__")return l;if(!s){const o=Object.getOwnPropertyDescriptor(e,t);Ce()&&(typeof l!="function"||e.hasOwnProperty(t))&&!(o&&o.get)&&(l=ae(r,t,l)())}return ee(l)?Qe(l):l},has(e,t){return t===pe||t===G||t===Ee||t===z||t===M||t==="__proto__"?!0:(Ce()&&ae($e(e,M),t)(),t in e)},set(){return!0},deleteProperty(){return!0},ownKeys:Mt,getOwnPropertyDescriptor:Dt};function ne(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[M]&&e[M][t]&&s!==void 0&&e[M][t].$()):(e[t]=n,e[M]&&e[M][t]&&s===void 0&&e[M][t].$());let o=$e(e,z),i;if((i=ae(o,t,s))&&i.$(()=>n),Array.isArray(e)&&e.length!==l){for(let a=e.length;a1){r=t.shift();const o=typeof r,i=Array.isArray(e);if(Array.isArray(r)){for(let a=0;a1){se(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=te(l),r===void 0||ee(s)&&ee(l)&&!Array.isArray(l)?tt(s,l):ne(e,r,l))}function Wt(...[e,t]){const n=te(e||{}),r=Array.isArray(n),s=Qe(n);function l(...o){bt(()=>{r&&o.length===1?Bt(n,o[0]):se(n,o)})}return[s,l]}const we=new WeakMap,nt={get(e,t){if(t===pe)return e;const n=e[t];let r;return ee(n)?we.get(n)||(we.set(n,r=new Proxy(n,nt)),r):n},set(e,t,n){return ne(e,t,te(n)),!0},deleteProperty(e,t){return ne(e,t,void 0,!0),!0}};function F(e){return t=>{if(ee(t)){let n;(n=we.get(t))||we.set(t,n=new Proxy(t,nt)),e(n)}return t}}const[Ut,Ht]=A(0);setInterval(()=>Ht(e=>e+1),5e3);function Le(e){const t={};for(const n of e.backends)t[n.name]=n.state;for(const n of e.frontends){let r=0;for(let a=0;a0){c=!0;break}if(c){r=a;break}}let s=!1,l=!1,o=!0;const i=new Set;for(let a=0;a0&&(s=!0),i.has(c.name)||(i.add(c.name),l=!0,u!=="unknown"&&(o=!1))}!l||o?n.state="unknown":s?n.state="up":n.state="down"}}const[Y,W]=Wt({byName:{},settling:{}}),Ft=2e3,ie=new Map;function Vt(e,t){return`${e}\0${t}`}function rt(e,t){W(F(s=>{s.settling[e]||(s.settling[e]={}),s.settling[e][t]=!0}));const n=Vt(e,t),r=ie.get(n);r&&clearTimeout(r),ie.set(n,setTimeout(()=>{ie.delete(n),W(F(s=>{s.settling[e]&&delete s.settling[e][t]}))},Ft))}function Kt(e){for(const[t,n]of ie)t.startsWith(e+"\0")&&(clearTimeout(n),ie.delete(t));W(F(t=>{t.settling[e]&&(t.settling[e]={})}))}function st(e){const t={};for(const n of e)Le(n),t[n.maglevd.name]=n;W({byName:t})}function Gt(e,t){W(F(r=>{const s=r.byName[e];if(!s)return;const l=s.backends.find(o=>o.name===t.backend);l&&(l.state=t.transition.to,l.enabled=t.transition.to!=="disabled",l.last_transition=t.transition,l.transitions||(l.transitions=[]),l.transitions.push(t.transition),l.transitions.length>20&&(l.transitions=l.transitions.slice(l.transitions.length-20)),Le(s))}));const n=Y.byName[e];if(n)for(const r of n.frontends)r.pools.some(s=>s.backends.some(l=>l.name===t.backend))&&rt(e,r.name)}function qt(e,t){W(F(n=>{const r=n.byName[e];if(!r)return;const s=t.per_frontend;if(!s||Object.keys(s).length===0){r.lb_state!==void 0&&(r.lb_state=void 0);return}r.lb_state||(r.lb_state={per_frontend:{}});const o=r.lb_state.per_frontend;for(const i of Object.keys(s)){o[i]||(o[i]={});const a=o[i],c=s[i];for(const u of Object.keys(c))a[u]!==c[u]&&(a[u]=c[u]);for(const u of Object.keys(a))u in c||delete a[u]}for(const i of Object.keys(o))i in s||delete o[i]})),Kt(e)}function Yt(e,t,n){return e?.lb_state?.per_frontend?.[t]?.[n]}function Jt(e,t){W(F(n=>{const r=n.byName[e];r&&(r.vpp_state=t)}))}function Xt(e,t){W(F(n=>{const r=n.byName[e];r&&(r.maglevd.connected=t.connected,r.maglevd.last_error=t.last_error)}))}function Zt(e,t,n,r,s){W(F(l=>{const o=l.byName[e];if(!o)return;const i=o.frontends.find(u=>u.name===t);if(!i)return;const a=i.pools.find(u=>u.name===n);if(!a)return;const c=a.backends.find(u=>u.name===r);c&&(c.weight=s,Le(o))})),rt(e,t)}function zt(e,t){const n={};for(const f of e.backends)n[f.name]=f.state;const r=!!e.lb_state,s=e.lb_state?.per_frontend?.[t.name],l=!!Y.settling[e.maglevd.name]?.[t.name];let o=!1,i=!1;for(const f of t.pools)for(const $ of f.backends)if(n[$.name]!=="up"&&(o=!0),!l&&r&&$.effective_weight>0){const g=s?.[$.name];(g===void 0||g===0)&&(i=!0)}const a=t.pools[0],c=!!a&&a.backends.some(f=>f.weight>0),u=!a||a.backends.every(f=>f.effective_weight===0);return!o&&c&&!i?"ok":i?"bug-buckets":u?"primary-drained":o?"degraded":"unknown"}function Qt(e,t){switch(zt(e,t)){case"ok":return"✅";case"bug-buckets":return"‼️";case"primary-drained":return"❗";case"degraded":return"⚠️";case"unknown":return"❓"}}function en(e,t){return e.includes(":")?`[${e}]:${t}`:`${e}:${t}`}function tn(e){if(Ut(),!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,o=Math.floor(s/60);if(o<1)return`${l}m${r}s ago`;const i=o%24,a=Math.floor(o/24);return a<1?`${o}h${l}m ago`:`${a}d${i}h ago`}const He=500,[ve,nn]=A([]);function rn(e){nn(t=>{const n=[...t,e];return n.length>He?n.slice(n.length-He):n})}const sn=1e4,ln=3e4;function on(){let e,t=!1;const n=i=>{try{const a=JSON.parse(i.data);an(a)}catch(a){console.error("sse parse error",a,i.data)}},r=async()=>{try{const i=await Ze();st(i)}catch(i){console.error("resync refetch failed",i)}},s=()=>{e&&(e.close(),e=void 0),e=new EventSource("/view/api/events"),e.onmessage=n,e.addEventListener("resync",r),e.onerror=i=>{console.debug("sse error, browser will reconnect",i)}},l=i=>{t||(t=!0,console.info("sse reconnecting:",i),s(),setTimeout(()=>{t=!1},1e3))};let o=Date.now();setInterval(()=>{const i=Date.now(),a=i-o;o=i,a>ln&&l(`wake detected (${Math.round(a/1e3)}s gap)`)},sn),s()}function an(e){switch(rn(e),e.type){case"backend":Gt(e.maglevd,e.payload);break;case"frontend":e.maglevd,e.payload;break;case"maglevd-status":Xt(e.maglevd,e.payload);break;case"vpp-status":Jt(e.maglevd,e.payload.state);break;case"lb-state":qt(e.maglevd,e.payload);break}}const[Se,lt]=A(void 0);var cn=b("