diff --git a/Makefile b/Makefile index f52bc32..7702358 100644 --- a/Makefile +++ b/Makefile @@ -57,8 +57,8 @@ $(FRONTEND_WEB_DIST): $(FRONTEND_WEB_SRC) cd cmd/frontend/web && npm install && npm run build pkg-deb: build-amd64 build-arm64 - debian/build-deb.sh amd64 $(VERSION) $(COMMIT_HASH) - debian/build-deb.sh arm64 $(VERSION) $(COMMIT_HASH) + debian/build-deb.sh amd64 $(VERSION) + debian/build-deb.sh arm64 $(VERSION) test: $(GEN_FILES) go test ./... diff --git a/README.md b/README.md index 71ad91f..8f041d7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ Requires Go 1.25+ and (for `make proto`) `protoc` with `protoc-gen-go` and Produces `vpp-maglev__amd64.deb` and `vpp-maglev__arm64.deb` in the `build/` directory by cross-compiling with `GOOS=linux GOARCH=`. -Requires `dpkg-deb` (available on any Debian/Ubuntu host). +Requires `dpkg-deb` (available on any Debian/Ubuntu host). The installed +binaries report the exact git commit via `maglevd --version` (and +similarly for `maglevc` / `maglev-frontend`). ## Running @@ -23,7 +25,7 @@ After installing, the unit is enabled but not started automatically: ```sh # edit /etc/vpp-maglev/maglev.yaml, then: -systemctl enable --now vpp-maglevd +systemctl enable --now vpp-maglev ``` Or run the server and client by hand: diff --git a/cmd/frontend/client.go b/cmd/frontend/client.go index 998eb3d..44c15a7 100644 --- a/cmd/frontend/client.go +++ b/cmd/frontend/client.go @@ -47,6 +47,7 @@ type cachedState struct { HealthChecks map[string]*HealthCheckSnapshot HealthCheckOrder []string VPPInfo *VPPInfoSnapshot + VPPState string // "", "connected", "disconnected" LastRefresh time.Time } @@ -93,6 +94,37 @@ func (c *maglevClient) Close() { _ = c.conn.Close() } +// BackendAction runs one of the four lifecycle operations on a backend. +// Valid actions are "pause", "resume", "enable", and "disable". The +// fresh backend snapshot returned by maglevd is converted and sent +// back to the caller so the admin API handler can reply with the +// post-mutation state in a single round-trip. The broadcast +// WatchEvents stream will also deliver a transition event which the +// local cache and every connected browser apply through the normal +// reducer path — so the UI converges even if the HTTP response is +// slow or dropped in flight. +func (c *maglevClient) BackendAction(ctx context.Context, name, action string) (*BackendSnapshot, error) { + req := &grpcapi.BackendRequest{Name: name} + var bi *grpcapi.BackendInfo + var err error + switch action { + case "pause": + bi, err = c.api.PauseBackend(ctx, req) + case "resume": + bi, err = c.api.ResumeBackend(ctx, req) + case "enable": + bi, err = c.api.EnableBackend(ctx, req) + case "disable": + bi, err = c.api.DisableBackend(ctx, req) + default: + return nil, fmt.Errorf("unknown action %q", action) + } + if err != nil { + return nil, err + } + return backendFromProto(bi), nil +} + func (c *maglevClient) Start(ctx context.Context) { go c.watchLoop(ctx) go c.refreshLoop(ctx) @@ -145,6 +177,7 @@ func (c *maglevClient) Snapshot() *StateSnapshot { Backends: make([]*BackendSnapshot, 0, len(c.cache.BackendsOrder)), HealthChecks: make([]*HealthCheckSnapshot, 0, len(c.cache.HealthCheckOrder)), VPPInfo: c.cache.VPPInfo, + VPPState: c.cache.VPPState, } for _, name := range c.cache.FrontendsOrder { if f, ok := c.cache.Frontends[name]; ok { @@ -214,6 +247,7 @@ func (c *maglevClient) refreshAll(ctx context.Context) error { } var vppInfo *VPPInfoSnapshot + vppState := "disconnected" if vi, err := c.api.GetVPPInfo(rctx, &grpcapi.GetVPPInfoRequest{}); err == nil { vppInfo = &VPPInfoSnapshot{ Version: vi.GetVersion(), @@ -222,9 +256,19 @@ func (c *maglevClient) refreshAll(ctx context.Context) error { BoottimeNs: vi.GetBoottimeNs(), ConnecttimeNs: vi.GetConnecttimeNs(), } + vppState = "connected" } c.mu.Lock() + // Frontend state comes from the FrontendEvent stream, not the + // FrontendInfo proto — carry any known state from the old cache over + // to the freshly-listed entries so a periodic refresh doesn't blank + // the state badges until the next live transition arrives. + for name, f := range frontends { + if old, ok := c.cache.Frontends[name]; ok && old.State != "" { + f.State = old.State + } + } c.cache.Frontends = frontends c.cache.FrontendsOrder = frontendsOrder c.cache.Backends = backends @@ -232,6 +276,7 @@ func (c *maglevClient) refreshAll(ctx context.Context) error { c.cache.HealthChecks = healthchecks c.cache.HealthCheckOrder = healthCheckOrder c.cache.VPPInfo = vppInfo + c.cache.VPPState = vppState c.cache.LastRefresh = time.Now() c.mu.Unlock() return nil @@ -314,6 +359,7 @@ func (c *maglevClient) handleEvent(ev *grpcapi.Event) { for _, a := range le.GetAttrs() { attrs[a.GetKey()] = a.GetValue() } + c.applyVPPLogHeartbeat(le.GetMsg()) payload, _ := json.Marshal(LogEventPayload{ Level: le.GetLevel(), Msg: le.GetMsg(), @@ -360,6 +406,12 @@ func (c *maglevClient) handleEvent(ev *grpcapi.Event) { return } tr := transitionFromProto(fe.GetTransition()) + // Always update the cached state — synthetic from==to events on + // subscribe are how we learn the initial frontend state (there's + // no equivalent field in the FrontendInfo proto). Only publish + // genuine transitions to the browser so the debug panel doesn't + // show 'up → up' spam on every gRPC reconnect. + c.applyFrontendState(fe.GetFrontendName(), tr.To) if tr.From == tr.To { return } @@ -376,6 +428,52 @@ func (c *maglevClient) handleEvent(ev *grpcapi.Event) { } } +// applyFrontendState writes the given state into the cached frontend +// snapshot. Called both by synthetic replay events on subscribe and by +// live transitions afterwards. +func (c *maglevClient) applyFrontendState(name, state string) { + c.mu.Lock() + defer c.mu.Unlock() + f, ok := c.cache.Frontends[name] + if !ok { + return + } + f.State = state +} + +// applyVPPLogHeartbeat flips the cache.VPPState field based on the +// event's msg. vpp-connect and vpp-api-{send,recv}* are treated as +// "VPP is up" signals; vpp-disconnect flips to "down". Unrelated log +// events are a no-op. Called from handleEvent under the client's +// event-dispatch goroutine, so contention on mu is single-writer. +func (c *maglevClient) applyVPPLogHeartbeat(msg string) { + var newState string + switch { + case msg == "vpp-connect": + newState = "connected" + case msg == "vpp-disconnect": + newState = "disconnected" + case strings.HasPrefix(msg, "vpp-api-send") || strings.HasPrefix(msg, "vpp-api-recv"): + newState = "connected" + default: + return + } + c.mu.Lock() + if c.cache.VPPState == newState { + c.mu.Unlock() + return + } + c.cache.VPPState = newState + c.mu.Unlock() + payload, _ := json.Marshal(VPPStatusPayload{State: newState}) + c.broker.Publish(BrowserEvent{ + Maglevd: c.name, + Type: "vpp-status", + AtUnixNs: time.Now().UnixNano(), + Payload: payload, + }) +} + func (c *maglevClient) applyBackendTransition(name string, tr *TransitionRecord) { c.mu.Lock() defer c.mu.Unlock() diff --git a/cmd/frontend/handlers.go b/cmd/frontend/handlers.go index 24a8453..b4149f3 100644 --- a/cmd/frontend/handlers.go +++ b/cmd/frontend/handlers.go @@ -3,6 +3,8 @@ package main import ( + "context" + "crypto/subtle" "encoding/json" "fmt" "io/fs" @@ -14,7 +16,18 @@ import ( buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd" ) -func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broker) { +// adminCreds holds the basic-auth credentials for the /admin/ surface. +// Enabled is true when both the user and password env vars were set +// and non-empty at startup; when false, /admin/ is hidden entirely +// (returns 404) so operators who never intended to expose it don't +// see a teasing "unauthorized" response. +type adminCreds struct { + User string + Password string + Enabled bool +} + +func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broker, admin adminCreds) { byName := make(map[string]*maglevClient, len(clients)) for _, c := range clients { byName[c.name] = c @@ -26,9 +39,10 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke mux.HandleFunc("/view/api/version", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, VersionInfo{ - Version: buildinfo.Version(), - Commit: buildinfo.Commit(), - Date: buildinfo.Date(), + Version: buildinfo.Version(), + Commit: buildinfo.Commit(), + Date: buildinfo.Date(), + AdminEnabled: admin.Enabled, }) }) @@ -62,10 +76,6 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke serveSSE(w, r, broker) }) - mux.HandleFunc("/admin/", func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "admin mode not implemented", http.StatusNotImplemented) - }) - // Static SPA served from the embedded dist fs, mounted under /view/. staticFS, err := fs.Sub(webFS, "web/dist") if err != nil { @@ -74,6 +84,35 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke } fileServer := http.FileServer(http.FS(staticFS)) mux.Handle("/view/", http.StripPrefix("/view/", fileServer)) + + // /admin/ serves the same SPA shell behind basic auth when the + // credentials are configured. Only the index.html is served here — + // all JS, CSS, and assets are referenced via absolute /view/assets/ + // URLs baked in by Vite, so they continue to load from the + // unauthenticated /view/ tree. Read-only API calls also go to + // /view/api/* unchanged. Mutation endpoints live under /admin/api/ + // so the same basic-auth middleware covers every writing path. + if admin.Enabled { + indexBytes, ierr := fs.ReadFile(staticFS, "index.html") + if ierr != nil { + slog.Error("embed-index", "err", ierr) + return + } + serveIndex := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(indexBytes) + }) + adminAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleAdminAPI(w, r, byName) + }) + realm := "maglev-frontend admin" + // Register /admin/api/ before /admin/ so the more specific + // pattern wins in net/http's ServeMux. + mux.Handle("/admin/api/", basicAuth(realm, admin.User, admin.Password, adminAPI)) + mux.Handle("/admin/", basicAuth(realm, admin.User, admin.Password, serveIndex)) + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.Redirect(w, r, "/view/", http.StatusFound) @@ -83,6 +122,76 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke }) } +// handleAdminAPI dispatches mutation requests under /admin/api/. +// +// Currently the only supported shape is: +// +// POST /admin/api/{maglevd}/backend/{name}/{pause|resume|enable|disable} +// +// 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). +func handleAdminAPI(w http.ResponseWriter, r *http.Request, byName map[string]*maglevClient) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/admin/api/"), "/") + // Expect: {maglevd} "backend" {name} {action} + if len(parts) != 4 || parts[1] != "backend" { + http.NotFound(w, r) + return + } + maglevd, name, action := parts[0], parts[2], parts[3] + c, ok := byName[maglevd] + if !ok { + http.NotFound(w, r) + return + } + switch action { + case "pause", "resume", "enable", "disable": + default: + http.Error(w, fmt.Sprintf("unknown action %q", action), http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + 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) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + slog.Info("admin-backend-action", + "maglevd", maglevd, "backend", name, "action", action, "state", snap.State) + writeJSON(w, snap) +} + +// basicAuth wraps a handler in an HTTP basic-auth check. Uses +// subtle.ConstantTimeCompare to avoid leaking credential length or +// content via response-timing side channels. +func basicAuth(realm, user, password string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + // Compare fixed-length byte slices so a wrong username takes + // the same time as a wrong password; only the boolean result + // matters. + uOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 + pOK := subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 + if !ok || !uOK || !pOK { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, realm)) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) diff --git a/cmd/frontend/main.go b/cmd/frontend/main.go index dabccb1..aca7bc1 100644 --- a/cmd/frontend/main.go +++ b/cmd/frontend/main.go @@ -69,8 +69,20 @@ func run() error { slog.Info("maglevd-configured", "name", c.name, "address", c.address) } + admin := adminCreds{ + User: os.Getenv("MAGLEV_FRONTEND_USER"), + Password: os.Getenv("MAGLEV_FRONTEND_PASSWORD"), + } + admin.Enabled = admin.User != "" && admin.Password != "" + if admin.Enabled { + slog.Info("admin-enabled", "user", admin.User) + } else { + slog.Info("admin-disabled", + "reason", "MAGLEV_FRONTEND_USER and MAGLEV_FRONTEND_PASSWORD must both be set and non-empty") + } + mux := http.NewServeMux() - registerHandlers(mux, clients, broker) + registerHandlers(mux, clients, broker, admin) srv := &http.Server{ Addr: *listen, diff --git a/cmd/frontend/types.go b/cmd/frontend/types.go index ec7f49e..a50c854 100644 --- a/cmd/frontend/types.go +++ b/cmd/frontend/types.go @@ -11,6 +11,10 @@ type StateSnapshot struct { Backends []*BackendSnapshot `json:"backends"` HealthChecks []*HealthCheckSnapshot `json:"healthchecks"` VPPInfo *VPPInfoSnapshot `json:"vpp_info,omitempty"` + // VPPState is "connected", "disconnected", or "" (unknown). Updated + // from vpp-connect / vpp-disconnect / vpp-api-{send,recv} log + // events and re-seeded on every refreshAll tick. + VPPState string `json:"vpp_state,omitempty"` } // MaglevdInfo is the per-maglevd connection status record. @@ -21,11 +25,13 @@ type MaglevdInfo struct { LastError string `json:"last_error,omitempty"` } -// VersionInfo is the build metadata of this maglev-frontend binary. +// VersionInfo is the build metadata of this maglev-frontend binary +// plus runtime capability flags the SPA needs to know at mount time. type VersionInfo struct { - Version string `json:"version"` - Commit string `json:"commit"` - Date string `json:"date"` + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + AdminEnabled bool `json:"admin_enabled"` } type FrontendSnapshot struct { @@ -36,6 +42,10 @@ type FrontendSnapshot struct { Description string `json:"description,omitempty"` SrcIPSticky bool `json:"src_ip_sticky"` Pools []*PoolSnapshot `json:"pools"` + // State is the aggregated frontend state ("up" | "down" | "unknown") + // populated from FrontendEvent messages, including the synthetic + // from==to replay that maglevd sends on WatchEvents subscribe. + State string `json:"state,omitempty"` } type PoolSnapshot struct { @@ -115,3 +125,11 @@ type MaglevdStatusPayload struct { Connected bool `json:"connected"` LastError string `json:"last_error,omitempty"` } + +// VPPStatusPayload rides on a "vpp-status" BrowserEvent and tells the +// SPA when the maglevd↔VPP connection flips. Emitted by the frontend's +// log-event handler on vpp-connect / vpp-disconnect, and on the first +// sighting of vpp-api-send/recv (which implies VPP is up). +type VPPStatusPayload struct { + State string `json:"state"` // "connected" | "disconnected" +} diff --git a/cmd/frontend/web/dist/assets/index-9NmAul22.css b/cmd/frontend/web/dist/assets/index-9NmAul22.css deleted file mode 100644 index b9a5008..0000000 --- a/cmd/frontend/web/dist/assets/index-9NmAul22.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: #1f2937;--fg-muted: #6b7280;--border: #e5e7eb;--accent: #2563eb;--state-up: #16a34a;--state-down: #dc2626;--state-paused: #2563eb;--state-disabled: #6b7280;--state-unknown: #eab308;--state-removed: #374151}.flash-target{display:inline-block;padding:0 4px;border-radius:3px}.app{max-width:1400px;margin:0 auto;padding:16px}.app-header{display:flex;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand strong{font-size:18px}.app-header .mode-tag{margin-left:auto;padding:2px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;text-transform:uppercase}.brand .version{margin-left:8px;color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;cursor:help}.admin-toggle{padding:4px 10px;border:1px solid var(--border);border-radius:4px;color:var(--accent)}.scope-selector{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-grid{display:grid;gap:16px;grid-template-columns:1fr}@media (min-width: 640px){.frontend-grid{grid-template-columns:1fr 1fr}}@media (min-width: 1024px){.frontend-grid{grid-template-columns:repeat(3,1fr)}}.frontend-card{background:var(--bg-card);border:1px solid var(--border);border-radius:6px;padding:12px}.frontend-header h2{font-size:16px;margin-bottom:4px}.frontend-meta{display:flex;gap:8px;color:var(--fg-muted);font-size:12px}.frontend-meta .proto{text-transform:uppercase;font-weight:600}.frontend-desc{font-size:12px;color:var(--fg-muted);margin-top:4px}.tag{display:inline-block;padding:1px 6px;border-radius:3px;background:var(--bg-soft);color:var(--fg-muted);font-size:11px;margin-left:4px}.pool-block{margin-top:12px}.pool-name{font-size:13px;color:var(--fg-muted);margin-bottom:4px}.backend-table th,.backend-table td{white-space:nowrap}.backend-table th{font-size:11px;color:var(--fg-muted);text-transform:uppercase;border-bottom:1px solid var(--border)}.backend-table .numeric{text-align:right}.backend-row td{border-bottom:1px solid var(--border);font-size:13px}.backend-row .backend-name{font-weight:500}.backend-row .backend-address,.backend-row .age{color:var(--fg-muted);font-family:SF Mono,Menlo,Consolas,monospace;font-size:12px}.probe-heartbeat{display:inline-block;width:16px;height:14px;line-height:14px;margin-right:6px;text-align:center;font-size:10px;color:var(--state-disabled);overflow:hidden;vertical-align:middle}.probe-heartbeat.in-flight{color:inherit}.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{margin-top:16px;border:1px solid var(--border);border-radius:6px;background:var(--bg-card)}.zippy summary{padding:8px 12px;cursor:pointer;font-weight:500}.zippy-body{padding:8px 12px;border-top:1px solid var(--border)}.kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px}.kv dt{color:var(--fg-muted)}.debug-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-AsNHMKdQ.js b/cmd/frontend/web/dist/assets/index-AsNHMKdQ.js new file mode 100644 index 0000000..453dc5a --- /dev/null +++ b/cmd/frontend/web/dist/assets/index-AsNHMKdQ.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 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("