From 25e9d79aba4b13fa1567fa95e68bd3f28a9d6c7a Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Sun, 12 Apr 2026 20:04:45 +0200 Subject: [PATCH] Frontend: live clocks, admin mode, backend actions; packaging polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the maglev-frontend component introduced in 284b4cc with quality-of-life improvements, an authenticated /admin surface, a live-action control plane, and Debian packaging cleanup. - Backend state now renders live: maglevd's FrontendEvent synthetic from==to replay hydrates FrontendSnapshot.State on WatchEvents subscribe, and live transitions update both the in-process cache and every connected browser via a new applyFrontendTransition reducer. Shown as a StatusBadge next to the frontend name. - VPP connection state surfaces in the VPP zippy title as a green/red badge. Driven by vpp-connect / vpp-disconnect and by the steady stream of vpp-api-send/recv debug heartbeats so a silent VPP drop is caught within one debug-log tick. - Probe heartbeat dot becomes ❤️ while a probe is in flight and reverts to · on probe-done. Fixed-size wrapper so the emoji swap doesn't jiggle the row; both states share the same font-size. - Flash component replaced its subtle background-only fade with a scale-pop + yellow halo box-shadow + longer duration so weight/effective/state changes are unmissable on tiny numeric cells. Initial mount still skipped via defer so no flash on load. - Last-transition age is now a live countdown driven by a global 1-second ticker signal (one timer, many subscribers). Two most significant units: 10m30s / 1h12m / 1d16h. Sub-second ages render as "now" to absorb clock skew between maglevd and the browser. - Event stream is now chronological (oldest at top) with tail- style auto-scroll, pause/resume, and the toolbar moved below the list. Row separators removed. Also shown only in /admin (see below) so /view stays a focused read-only surface. - Table nowrap so backend names like nginx0-frggh0 and the "last transition" header don't wrap. Frontends render in the order returned by ListFrontends instead of Go map iteration order so reload doesn't shuffle VIP order. - IPng logo in the header, clickable, links to the git repo. Header padding reduced so the logo can fill the bar up to the separator. Version + commit + build date shown in the brand area (fetched once from /view/api/version). - "view" / "admin" mode tag moved to sit just left of the admin toggle button so it reads as a pair. - Prettier wired in as the web-side fixstyle via a new fixstyle-web Make target that also runs from `make fixstyle`. Added .prettierrc.json and .prettierignore; 8 existing files were normalized in place. - Fixed a "20555d ago" rendering bug: maglevd's synthetic backend-replay events (from==to, at_unix_ns=0) were corrupting the local cache's LastTransition via applyBackendTransition. Backend synthetic events are now skipped entirely (refreshAll covers initial hydration for backends), while frontend synthetic events are still applied because FrontendInfo doesn't carry state — the event is the only source. - New MAGLEV_FRONTEND_USER / MAGLEV_FRONTEND_PASSWORD env vars. When both are set and non-empty, /admin/ becomes a basic-auth- protected SPA shell backed by the same embedded index.html as /view/. The SPA detects its base path via a new stores/mode.ts isAdmin constant and conditionally renders admin-only sections (currently: the Event Stream / DebugPanel). When disabled, /admin/ returns 404 (not 501) so operators who didn't configure it see no teasing affordance, and the SPA's admin-toggle button is hidden entirely via the admin_enabled flag on /view/api/version. - basicAuth uses crypto/subtle.ConstantTimeCompare for both user and password so timing can't distinguish a wrong username from a wrong password. - New POST /admin/api/{maglevd}/backend/{name}/{pause|resume| enable|disable} endpoint, gated by the same basic-auth middleware as the SPA shell. maglevClient.BackendAction wraps the four matching gRPC RPCs and returns a fresh BackendSnapshot; the same transition lands via WatchEvents so every connected browser converges through the normal reducer path. - BackendActionsMenu Solid component: kebab (⋮) button in a new trailing column rendered only in /admin. Click-outside and Escape close the popover (document listeners installed only while open). Actions are state-aware: up/down/unknown → pause, disable; paused → resume, disable; disabled → enable; removed → menu suppressed entirely. Busy indicator per action; errors render inline under the item list. - Structured audit log: every mutation logs an admin-backend-action record with maglevd / backend / action / resulting state. - Renamed debian/vpp-maglevd.service → debian/vpp-maglev.service to align naming with the new vpp-maglev-frontend.service sibling. postinst handles upgrades by stopping + disabling any lingering vpp-maglevd.service before enabling the renamed unit; prerm stops both (the frontend unit is installed but not enabled by default — operators opt in with systemctl enable). - New debian/vpp-maglev-frontend.service (hardened: NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, no capabilities). Reads the same /etc/default/vpp-maglev conffile and expands MAGLEV_FRONTEND_ARGS via `ExecStart=/usr/bin/maglev-frontend $MAGLEV_FRONTEND_ARGS` so word-splitting works. - docs/maglev-frontend.8 manpage documenting flags, endpoints, and SSE reverse-proxy requirements. - build-deb.sh: drops the commit hash from the .deb filename (now vpp-maglev__.deb) and no longer takes the commit as a CLI arg. Binaries continue to carry the commit via -ldflags so `maglevd --version` et al are the authoritative "which build is running" answer. --- Makefile | 4 +- README.md | 6 +- cmd/frontend/client.go | 98 ++++++++++ cmd/frontend/handlers.go | 125 +++++++++++- cmd/frontend/main.go | 14 +- cmd/frontend/types.go | 26 ++- .../web/dist/assets/index-9NmAul22.css | 1 - .../web/dist/assets/index-AsNHMKdQ.js | 1 + .../web/dist/assets/index-CrBeXDdb.css | 1 + .../web/dist/assets/index-DZzDfClm.js | 1 - .../web/dist/assets/logo-bimi-Bguc6E_L.svg | 1 + cmd/frontend/web/dist/index.html | 4 +- cmd/frontend/web/src/App.tsx | 51 +++-- cmd/frontend/web/src/api/admin.ts | 24 +++ cmd/frontend/web/src/api/sse.ts | 5 + cmd/frontend/web/src/assets/logo-bimi.svg | 1 + .../web/src/components/BackendActionsMenu.tsx | 126 +++++++++++++ cmd/frontend/web/src/components/Flash.tsx | 61 ++++-- cmd/frontend/web/src/components/Zippy.tsx | 2 +- cmd/frontend/web/src/env.d.ts | 6 + cmd/frontend/web/src/stores/mode.ts | 5 + cmd/frontend/web/src/stores/state.ts | 59 ++++-- cmd/frontend/web/src/stores/tick.ts | 18 ++ cmd/frontend/web/src/styles/theme.css | 118 +++++++++++- cmd/frontend/web/src/types.ts | 9 +- cmd/frontend/web/src/views/BackendRow.tsx | 9 +- cmd/frontend/web/src/views/FrontendCard.tsx | 15 +- cmd/frontend/web/src/views/Overview.tsx | 2 +- cmd/frontend/web/src/views/VPPInfoPanel.tsx | 59 ++++-- debian/build-deb.sh | 34 ++-- debian/control.in | 7 +- debian/default.vpp-maglev | 22 ++- debian/postinst | 15 +- debian/prerm | 6 +- debian/vpp-maglev-frontend.service | 23 +++ ...vpp-maglevd.service => vpp-maglev.service} | 0 docs/maglev-frontend.8 | 178 ++++++++++++++++++ 37 files changed, 1030 insertions(+), 107 deletions(-) delete mode 100644 cmd/frontend/web/dist/assets/index-9NmAul22.css create mode 100644 cmd/frontend/web/dist/assets/index-AsNHMKdQ.js create mode 100644 cmd/frontend/web/dist/assets/index-CrBeXDdb.css delete mode 100644 cmd/frontend/web/dist/assets/index-DZzDfClm.js create mode 100644 cmd/frontend/web/dist/assets/logo-bimi-Bguc6E_L.svg create mode 100644 cmd/frontend/web/src/api/admin.ts create mode 100644 cmd/frontend/web/src/assets/logo-bimi.svg create mode 100644 cmd/frontend/web/src/components/BackendActionsMenu.tsx create mode 100644 cmd/frontend/web/src/env.d.ts create mode 100644 cmd/frontend/web/src/stores/mode.ts create mode 100644 cmd/frontend/web/src/stores/tick.ts create mode 100644 debian/vpp-maglev-frontend.service rename debian/{vpp-maglevd.service => vpp-maglev.service} (100%) create mode 100644 docs/maglev-frontend.8 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("