New maglev-frontend component; promote LB sync events to INFO
Introduces maglev-frontend, a responsive, real-time web dashboard for one
or more running maglevd instances. Source lives at cmd/frontend/; the
built binary is maglev-frontend. It is a single Go process with the
SolidJS SPA embedded via //go:embed — no runtime file dependencies.
Architecture
- One persistent gRPC connection per configured maglevd (-server A,B,C).
Each connection runs three background loops: a WatchEvents stream
subscribed at log_level=debug for live events, a 30s refresh loop as
a safety net for drift, and a 5s health loop that surfaces connection
drops quickly.
- In-process pub/sub broker with a 30s / 2000-event replay ring using
<epoch>-<seq> monotonic IDs. Short browser reconnects (nginx idle,
wifi flap, laptop wake) silently replay buffered events via the
EventSource Last-Event-ID header; longer outages or frontend restarts
fall through to a "resync" event that triggers a full state refetch.
- HTTP surface: /view/ (SPA), /view/api/state, /view/api/state/{name},
/view/api/maglevds, /view/api/version, /view/api/events (SSE),
/healthz, and an /admin/* placeholder returning 501 for a future
basic-auth mutation surface.
- SSE handler follows the full operational checklist: retry hint, 15s
: ping heartbeat, Flush after every write, r.Context().Done() teardown,
X-Accel-Buffering: no, and no gzip.
SolidJS SPA (cmd/frontend/web/, Vite + TypeScript)
- solid-js/store for a reactive per-maglevd state tree; reducers apply
backend transitions, maglevd-status flips, and resync refetches.
- Scope selector tabs for multi-maglevd support, per-maglevd frontend
cards with pool tables showing state, configured weight, effective
weight, and last-transition age.
- ProbeHeartbeat component turns a middle-dot into ❤️ on probe-start and
back on probe-done, driven by real log events; fixed-size wrapper so
the emoji swap doesn't jiggle the row.
- Flash wrapper animates any primitive on change (1s yellow fade via
Web Animations API, skipped on first mount). Wired into the state
badge, configured weight, and effective weight columns.
- DebugPanel: chronological rolling event tail with tail-style auto-
scroll, pause/resume, and scope/firehose filter. Syntactic highlight
for vpp-lb-sync-* events with fixed-order attribute formatting.
- Live effective_weight updates: vpp-lb-sync-as-added/removed/weight-
updated log events are routed through a reducer that walks the
snapshot's pool rows and sets effective_weight on every match
without waiting for the 30s refresh.
- Header shows build version + commit with build date in a tooltip,
fetched once from /view/api/version on mount.
- Prettier wired in as the web-side fixstyle; make fixstyle now tidies
both Go and web in one shot via a new fixstyle-web target.
Per-mutation VPP LB sync logging
- Promotes the addVIP/delVIP/addAS/delAS/setASWeight helpers from
slog.Debug to slog.Info and renames them from vpp-lbsync-* to
vpp-lb-sync-{vip-added,vip-removed,as-added,as-removed,as-weight-
updated}. Matching rename for vpp-lb-sync-start / -done / -error /
-vip-recreate. The Prometheus metric name (maglev_vpp_lbsync_total)
is left alone to preserve dashboards.
- setASWeight now takes the prior weight so the event can emit
from=X to=Y and the UI can show the delta.
- The vip field in every event is the bare address (no /32 or /128
mask), matching the CLI output style.
- Any listener on the gRPC WatchEvents stream — CLI watch events or
maglev-frontend — now sees every VIP/AS dataplane change in real
time without needing to raise the log level.
Build and tooling
- Makefile: maglev-frontend added to BINARIES; build / build-amd64 /
build-arm64 emit the binary alongside maglevd and maglevc. A new
maglev-frontend-web target rebuilds the SolidJS bundle via npm.
- web/dist/ is tracked so a bare `go build` keeps working for Go-only
contributors and CI.
- .gitignore skips cmd/frontend/web/node_modules/.
Stability fixes
- maglevd's WatchEvents synthetic replay events (from==to, at_unix_ns=0)
were corrupting the frontend's LastTransition cache with at=0,
rendering as "20555d ago" in the browser. Client now skips synthetic
events: the cache comes from refreshAll and doesn't need them.
- Frontends, Backends, and HealthChecks are now served in the order
returned by the corresponding List* RPC instead of Go map iteration
order, so reloads and refreshes keep the SPA stable.
This commit is contained in:
164
cmd/frontend/handlers.go
Normal file
164
cmd/frontend/handlers.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
|
||||
)
|
||||
|
||||
func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broker) {
|
||||
byName := make(map[string]*maglevClient, len(clients))
|
||||
for _, c := range clients {
|
||||
byName[c.name] = c
|
||||
}
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/view/api/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, VersionInfo{
|
||||
Version: buildinfo.Version(),
|
||||
Commit: buildinfo.Commit(),
|
||||
Date: buildinfo.Date(),
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/view/api/maglevds", func(w http.ResponseWriter, _ *http.Request) {
|
||||
infos := make([]MaglevdInfo, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
infos = append(infos, c.Info())
|
||||
}
|
||||
writeJSON(w, infos)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/view/api/state", func(w http.ResponseWriter, _ *http.Request) {
|
||||
out := make([]*StateSnapshot, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
out = append(out, c.Snapshot())
|
||||
}
|
||||
writeJSON(w, out)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/view/api/state/", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/view/api/state/")
|
||||
c, ok := byName[name]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, c.Snapshot())
|
||||
})
|
||||
|
||||
mux.HandleFunc("/view/api/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
slog.Error("embed-subfs", "err", err)
|
||||
return
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(staticFS))
|
||||
mux.Handle("/view/", http.StripPrefix("/view/", fileServer))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/view/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
slog.Error("json-encode", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serveSSE handles the long-lived /view/api/events stream. The operational
|
||||
// requirements (retry hint, heartbeat, flush-after-write, X-Accel-Buffering,
|
||||
// context-done teardown) are documented in PLAN_FRONTEND.md §SSE operational
|
||||
// requirements.
|
||||
func serveSSE(w http.ResponseWriter, r *http.Request, broker *Broker) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Reconnect hint: EventSource default is 3–5s; 2s feels livelier.
|
||||
fmt.Fprintf(w, "retry: 2000\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
result := broker.Subscribe(r.Header.Get("Last-Event-ID"))
|
||||
defer broker.Unsubscribe(result.Channel)
|
||||
|
||||
if result.NeedResync {
|
||||
// No id: line — the browser keeps whatever Last-Event-ID it had,
|
||||
// so subsequent reconnects compare against a real event ID.
|
||||
fmt.Fprintf(w, "event: resync\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
for _, ev := range result.ReplayEvents {
|
||||
if err := writeEvent(w, ev); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case ev, ok := <-result.Channel:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeEvent(w, ev); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeEvent(w http.ResponseWriter, ev deliveredEvent) error {
|
||||
body, err := json.Marshal(ev.Event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "id: %s\ndata: %s\n\n", ev.ID, body)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user