Frontend: live clocks, admin mode, backend actions; packaging polish
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_<version>_<arch>.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.
This commit is contained in:
4
Makefile
4
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 ./...
|
||||
|
||||
@@ -15,7 +15,9 @@ Requires Go 1.25+ and (for `make proto`) `protoc` with `protoc-gen-go` and
|
||||
|
||||
Produces `vpp-maglev_<version>_amd64.deb` and `vpp-maglev_<version>_arm64.deb`
|
||||
in the `build/` directory by cross-compiling with `GOOS=linux GOARCH=<arch>`.
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +42,7 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
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"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
cmd/frontend/web/dist/assets/index-AsNHMKdQ.js
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-AsNHMKdQ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/frontend/web/dist/assets/index-CrBeXDdb.css
vendored
Normal file
1
cmd/frontend/web/dist/assets/index-CrBeXDdb.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/frontend/web/dist/assets/logo-bimi-Bguc6E_L.svg
vendored
Normal file
1
cmd/frontend/web/dist/assets/logo-bimi-Bguc6E_L.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.3 KiB |
4
cmd/frontend/web/dist/index.html
vendored
4
cmd/frontend/web/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>maglev</title>
|
||||
<script type="module" crossorigin src="/view/assets/index-DZzDfClm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/view/assets/index-9NmAul22.css">
|
||||
<script type="module" crossorigin src="/view/assets/index-AsNHMKdQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/view/assets/index-CrBeXDdb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, type Component } from "solid-js";
|
||||
import { Show, createSignal, onMount, type Component } from "solid-js";
|
||||
import { fetchAllState, fetchVersion } from "./api/rest";
|
||||
import { openEventStream } from "./api/sse";
|
||||
import { replaceAll, state } from "./stores/state";
|
||||
@@ -7,8 +7,8 @@ import ScopeSelector from "./components/ScopeSelector";
|
||||
import Overview from "./views/Overview";
|
||||
import DebugPanel from "./views/DebugPanel";
|
||||
import type { VersionInfo } from "./types";
|
||||
|
||||
const isAdmin = window.location.pathname.startsWith("/admin");
|
||||
import logoUrl from "./assets/logo-bimi.svg";
|
||||
import { isAdmin } from "./stores/mode";
|
||||
|
||||
const App: Component = () => {
|
||||
const [error, setError] = createSignal<string | undefined>();
|
||||
@@ -28,10 +28,24 @@ const App: Component = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The admin toggle is only shown when the server reports that admin
|
||||
// credentials are configured. Without this gate, we'd link operators
|
||||
// to a /admin/ URL that returns 404 and surprise them.
|
||||
const adminAvailable = () => version()?.admin_enabled === true;
|
||||
|
||||
return (
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a
|
||||
class="brand-logo"
|
||||
href="https://git.ipng.ch/ipng/vpp-maglev/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="vpp-maglev on git.ipng.ch"
|
||||
>
|
||||
<img src={logoUrl} alt="IPng" />
|
||||
</a>
|
||||
<strong>maglev</strong>
|
||||
{version() && (
|
||||
<span class="version" title={`commit ${version()!.commit} · built ${version()!.date}`}>
|
||||
@@ -40,7 +54,10 @@ const App: Component = () => {
|
||||
)}
|
||||
</div>
|
||||
<ScopeSelector />
|
||||
<Show when={isAdmin || adminAvailable()}>
|
||||
<span class="mode-tag">{isAdmin ? "admin" : "view"}</span>
|
||||
</Show>
|
||||
<Show when={adminAvailable()}>
|
||||
<a
|
||||
class="admin-toggle"
|
||||
href={isAdmin ? "/view/" : "/admin/"}
|
||||
@@ -48,13 +65,23 @@ const App: Component = () => {
|
||||
>
|
||||
{isAdmin ? "exit admin" : "admin…"}
|
||||
</a>
|
||||
</Show>
|
||||
</header>
|
||||
|
||||
{error() && <div class="banner err">{error()}</div>}
|
||||
{!error() && Object.keys(state.byName).length === 0 && <p class="loading">Loading…</p>}
|
||||
|
||||
<Overview />
|
||||
|
||||
{/* Admin-only sections — everything below this line is hidden on
|
||||
/view/ and only rendered when the SPA was loaded at /admin/.
|
||||
The basic-auth gate on /admin/ means only authenticated users
|
||||
ever reach this branch. Future admin-only features drop in
|
||||
here, one per <Show> block, so the /view-vs-/admin diff stays
|
||||
localized and reviewable. */}
|
||||
<Show when={isAdmin}>
|
||||
<DebugPanel />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
24
cmd/frontend/web/src/api/admin.ts
Normal file
24
cmd/frontend/web/src/api/admin.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { BackendSnapshot } from "../types";
|
||||
|
||||
export type BackendAction = "pause" | "resume" | "enable" | "disable";
|
||||
|
||||
// Run one of the four backend lifecycle operations on the given
|
||||
// maglevd. Returns the fresh BackendSnapshot from the server so the
|
||||
// caller can update the store immediately; the WatchEvents stream
|
||||
// will also deliver a transition event which the normal reducer
|
||||
// applies, so the store converges even if this HTTP response is
|
||||
// slow. Throws on non-2xx with the server's error body as the
|
||||
// message, which is enough signal for a toast/alert to surface.
|
||||
export async function runBackendAction(
|
||||
maglevd: string,
|
||||
backend: string,
|
||||
action: BackendAction,
|
||||
): Promise<BackendSnapshot> {
|
||||
const url = `/admin/api/${encodeURIComponent(maglevd)}/backend/${encodeURIComponent(backend)}/${action}`;
|
||||
const r = await fetch(url, { method: "POST", credentials: "same-origin" });
|
||||
if (!r.ok) {
|
||||
const body = (await r.text()).trim();
|
||||
throw new Error(body || `${r.status} ${r.statusText}`);
|
||||
}
|
||||
return (await r.json()) as BackendSnapshot;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
FrontendEventPayload,
|
||||
LogEventPayload,
|
||||
MaglevdStatusPayload,
|
||||
VPPStatusPayload,
|
||||
} from "../types";
|
||||
import { fetchAllState } from "./rest";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
applyBackendTransition,
|
||||
applyFrontendTransition,
|
||||
applyMaglevdStatus,
|
||||
applyVPPStatus,
|
||||
replaceAll,
|
||||
} from "../stores/state";
|
||||
import { pushEvent } from "../stores/events";
|
||||
@@ -62,6 +64,9 @@ function dispatch(ev: BrowserEvent) {
|
||||
case "maglevd-status":
|
||||
applyMaglevdStatus(ev.maglevd, ev.payload as MaglevdStatusPayload);
|
||||
break;
|
||||
case "vpp-status":
|
||||
applyVPPStatus(ev.maglevd, (ev.payload as VPPStatusPayload).state);
|
||||
break;
|
||||
case "log":
|
||||
applyLogEvent(ev.maglevd, ev.payload as LogEventPayload);
|
||||
break;
|
||||
|
||||
1
cmd/frontend/web/src/assets/logo-bimi.svg
Normal file
1
cmd/frontend/web/src/assets/logo-bimi.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.3 KiB |
126
cmd/frontend/web/src/components/BackendActionsMenu.tsx
Normal file
126
cmd/frontend/web/src/components/BackendActionsMenu.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { For, Show, createEffect, createSignal, onCleanup, type Component } from "solid-js";
|
||||
import { runBackendAction, type BackendAction } from "../api/admin";
|
||||
|
||||
type Props = {
|
||||
maglevd: string;
|
||||
backend: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
action: BackendAction;
|
||||
};
|
||||
|
||||
// Action set available per current backend state. Only the actions
|
||||
// that make sense for the current state are shown — e.g. "resume" is
|
||||
// meaningless on a running backend, and "enable" is meaningless on
|
||||
// anything except a disabled one. A backend in the "removed" state
|
||||
// has no actionable operations, so the whole kebab is suppressed.
|
||||
function itemsForState(state: string): MenuItem[] {
|
||||
switch (state) {
|
||||
case "up":
|
||||
case "down":
|
||||
case "unknown":
|
||||
return [
|
||||
{ label: "pause", action: "pause" },
|
||||
{ label: "disable", action: "disable" },
|
||||
];
|
||||
case "paused":
|
||||
return [
|
||||
{ label: "resume", action: "resume" },
|
||||
{ label: "disable", action: "disable" },
|
||||
];
|
||||
case "disabled":
|
||||
return [{ label: "enable", action: "enable" }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const BackendActionsMenu: Component<Props> = (props) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [busy, setBusy] = createSignal<BackendAction | undefined>();
|
||||
const [error, setError] = createSignal<string | undefined>();
|
||||
let wrapRef: HTMLDivElement | undefined;
|
||||
|
||||
const items = () => itemsForState(props.state);
|
||||
|
||||
// Close on outside-click or Escape. The effect only installs its
|
||||
// document listeners while the menu is open, so there's no cost on
|
||||
// the typical closed-at-rest state.
|
||||
createEffect(() => {
|
||||
if (!open()) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!wrapRef) return;
|
||||
if (!wrapRef.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
});
|
||||
});
|
||||
|
||||
const run = async (action: BackendAction) => {
|
||||
setBusy(action);
|
||||
setError(undefined);
|
||||
try {
|
||||
await runBackendAction(props.maglevd, props.backend, action);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(`${err}`);
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// If there are no valid actions for the current state, render
|
||||
// nothing — the surrounding <td> stays an empty cell, no "dead"
|
||||
// kebab tempting clicks.
|
||||
return (
|
||||
<Show when={items().length > 0}>
|
||||
<div class="kebab-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-btn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open()}
|
||||
title="backend actions"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
{"\u22EE"}
|
||||
</button>
|
||||
<Show when={open()}>
|
||||
<div class="kebab-menu" role="menu">
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-item"
|
||||
role="menuitem"
|
||||
disabled={busy() !== undefined}
|
||||
onClick={() => run(item.action)}
|
||||
>
|
||||
{busy() === item.action ? `${item.label}…` : item.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<p class="kebab-error">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackendActionsMenu;
|
||||
@@ -1,20 +1,28 @@
|
||||
import { createEffect, on, type Component, type JSX } from "solid-js";
|
||||
|
||||
type Props = {
|
||||
// value is only used for change detection. When it changes the
|
||||
// wrapper runs a 1s flash animation.
|
||||
// value is used solely for change detection. When it changes the
|
||||
// wrapper runs a short attention-grabbing animation.
|
||||
value: string | number | boolean;
|
||||
// When children are provided they are rendered inside the wrapper
|
||||
// instead of the raw value. Useful for wrapping e.g. <StatusBadge>
|
||||
// so the pill animates on state change while still showing itself.
|
||||
// When children are provided they render inside the wrapper instead
|
||||
// of the raw value. Useful for wrapping e.g. <StatusBadge> so the
|
||||
// pill animates on state change while still showing itself.
|
||||
children?: JSX.Element;
|
||||
};
|
||||
|
||||
// Flash plays a 1s yellow-to-transparent background animation every
|
||||
// time `value` changes. The initial mount is skipped (defer: true) so
|
||||
// nothing flashes on page load. Uses the Web Animations API so repeated
|
||||
// changes reliably re-trigger even when the new value arrives while a
|
||||
// previous animation is still running.
|
||||
// Flash plays a scale-pop + yellow glow every time `value` changes. The
|
||||
// scale component is the primary "something changed" signal — human
|
||||
// peripheral vision is tuned to motion, so a brief 1.35× bounce draws
|
||||
// the eye far more reliably than a static background-color change on a
|
||||
// tiny numeric cell. A box-shadow halo extends the colored area beyond
|
||||
// the text bounds so the effect reads even on 1–3 character values, and
|
||||
// the fade-back after-glow gives late glances a chance to catch which
|
||||
// field moved. Initial mount is skipped via defer so nothing flashes on
|
||||
// page load.
|
||||
//
|
||||
// The Web Animations API supersedes any still-running animation on the
|
||||
// same element, so repeat changes re-trigger cleanly without needing a
|
||||
// forced reflow.
|
||||
const Flash: Component<Props> = (props) => {
|
||||
let el: HTMLSpanElement | undefined;
|
||||
|
||||
@@ -22,10 +30,35 @@ const Flash: Component<Props> = (props) => {
|
||||
on(
|
||||
() => props.value,
|
||||
() => {
|
||||
el?.animate([{ backgroundColor: "#fefe27" }, { backgroundColor: "transparent" }], {
|
||||
duration: 1000,
|
||||
easing: "ease-out",
|
||||
});
|
||||
el?.animate(
|
||||
[
|
||||
{
|
||||
transform: "scale(1)",
|
||||
backgroundColor: "#facc15",
|
||||
boxShadow: "0 0 0 2px #facc15",
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
transform: "scale(1.35)",
|
||||
backgroundColor: "#facc15",
|
||||
boxShadow: "0 0 0 4px #facc15",
|
||||
offset: 0.18,
|
||||
},
|
||||
{
|
||||
transform: "scale(1)",
|
||||
backgroundColor: "#facc15",
|
||||
boxShadow: "0 0 0 2px #facc15",
|
||||
offset: 0.5,
|
||||
},
|
||||
{
|
||||
transform: "scale(1)",
|
||||
backgroundColor: "transparent",
|
||||
boxShadow: "0 0 0 0 transparent",
|
||||
offset: 1,
|
||||
},
|
||||
],
|
||||
{ duration: 1500, easing: "ease-out" },
|
||||
);
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Component, JSX } from "solid-js";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
title: JSX.Element;
|
||||
open?: boolean;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
6
cmd/frontend/web/src/env.d.ts
vendored
Normal file
6
cmd/frontend/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.svg" {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
5
cmd/frontend/web/src/stores/mode.ts
Normal file
5
cmd/frontend/web/src/stores/mode.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// isAdmin is fixed at page-load based on the URL. The SAME App.tsx
|
||||
// drives both /view/* and /admin/* — importing this constant lets any
|
||||
// component render admin-only affordances (e.g. the backend actions
|
||||
// kebab) without prop-drilling through the whole render tree.
|
||||
export const isAdmin = window.location.pathname.startsWith("/admin");
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
StateSnapshot,
|
||||
TransitionRecord,
|
||||
} from "../types";
|
||||
import { tick } from "./tick";
|
||||
|
||||
// FrontendState keys snapshots by maglevd name. A single store drives the
|
||||
// whole UI; reducers produce() into the right branch.
|
||||
@@ -49,12 +50,26 @@ export function applyBackendTransition(maglevd: string, p: BackendEventPayload)
|
||||
);
|
||||
}
|
||||
|
||||
export function applyFrontendTransition(maglevd: string, _p: FrontendEventPayload) {
|
||||
// Frontend roll-up state is computed per render in the current cut, so
|
||||
// there is nothing to update in the store. Kept as a named reducer so
|
||||
// the SSE dispatcher has one entry per event type and future frontend
|
||||
// state fields have a single place to land.
|
||||
void maglevd;
|
||||
export function applyFrontendTransition(maglevd: string, p: FrontendEventPayload) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const snap = s.byName[maglevd];
|
||||
if (!snap) return;
|
||||
const fe = snap.frontends.find((x) => x.name === p.frontend);
|
||||
if (!fe) return;
|
||||
fe.state = p.transition.to;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyVPPStatus(maglevd: string, state: string) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const snap = s.byName[maglevd];
|
||||
if (!snap) return;
|
||||
snap.vpp_state = state;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyMaglevdStatus(maglevd: string, p: MaglevdStatusPayload) {
|
||||
@@ -95,13 +110,31 @@ export function applyBackendEffectiveWeight(maglevd: string, address: string, we
|
||||
// Helpers used by views.
|
||||
|
||||
export function lastTransitionAge(t?: TransitionRecord): string {
|
||||
// Subscribe to the 1s ticker so the age string updates live as a
|
||||
// real-time countdown. No effect on layout — the age column is
|
||||
// unwrapped so the Flash animation never fires for these periodic
|
||||
// updates.
|
||||
tick();
|
||||
if (!t || !t.at_unix_ns || t.at_unix_ns <= 0) return "";
|
||||
const ms = Date.now() - t.at_unix_ns / 1e6;
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
// Clock skew between maglevd and the browser, plus the fact that
|
||||
// "1s ago" reads awkwardly, means anything at or below 1s is best
|
||||
// rendered as "now". Also catches negative values from a future-
|
||||
// skewed server clock.
|
||||
if (totalSec <= 1) return "now";
|
||||
// Render the two most significant units so fresh transitions show
|
||||
// sub-minute detail ("10m30s") while older transitions round cleanly
|
||||
// ("1d16h"). A single unit is shown only below one minute, since
|
||||
// "Xs" has nothing smaller beneath it.
|
||||
const s = totalSec % 60;
|
||||
const totalMin = Math.floor(totalSec / 60);
|
||||
if (totalMin < 1) return `${totalSec}s ago`;
|
||||
const m = totalMin % 60;
|
||||
const totalHr = Math.floor(totalMin / 60);
|
||||
if (totalHr < 1) return `${m}m${s}s ago`;
|
||||
const h = totalHr % 24;
|
||||
const d = Math.floor(totalHr / 24);
|
||||
if (d < 1) return `${totalHr}h${m}m ago`;
|
||||
return `${d}d${h}h ago`;
|
||||
}
|
||||
|
||||
18
cmd/frontend/web/src/stores/tick.ts
Normal file
18
cmd/frontend/web/src/stores/tick.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
// Global 5-second ticker used by relative-time helpers so their output
|
||||
// renders as a live countdown. At this cadence the age column reads like
|
||||
// a real-time clock for fresh transitions ("5s ago", "6s ago", ...) and
|
||||
// the cost is a single reactive re-run per subscribed cell per second —
|
||||
// no network, no DOM allocation, just string formatting. Identical-value
|
||||
// updates (e.g. "2h ago" → "2h ago") are cheap because Solid's JSX
|
||||
// compiler skips DOM writes when the new text matches the old.
|
||||
//
|
||||
// Reading tick() inside any reactive expression subscribes that
|
||||
// expression to the ticker; when the interval fires, every subscriber
|
||||
// re-runs. One timer drives the whole app.
|
||||
const [tick, setTick] = createSignal(0);
|
||||
|
||||
setInterval(() => setTick((t) => t + 1), 5_000);
|
||||
|
||||
export { tick };
|
||||
@@ -19,6 +19,13 @@
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
transform-origin: center center;
|
||||
will-change: transform, background-color, box-shadow;
|
||||
}
|
||||
/* The box-shadow halo used by Flash can extend past the cell; make sure
|
||||
* row cells don't clip it and the animation reads in full. */
|
||||
.backend-row td {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app {
|
||||
@@ -31,14 +38,31 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
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-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.brand-logo img {
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
display: block;
|
||||
}
|
||||
.brand-logo:hover img {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.app-header .mode-tag {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
@@ -152,6 +176,9 @@
|
||||
.frontend-header h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.frontend-meta {
|
||||
display: flex;
|
||||
@@ -213,6 +240,75 @@
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.backend-table th.actions,
|
||||
.backend-row td.actions {
|
||||
width: 24px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- kebab menu ---- */
|
||||
|
||||
.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 rgba(0, 0, 0, 0.12);
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---- probe heartbeat ---- */
|
||||
|
||||
@@ -281,6 +377,26 @@
|
||||
padding: 8px 12px;
|
||||
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: white;
|
||||
}
|
||||
.vpp-badge[data-state="connected"] {
|
||||
background: var(--state-up);
|
||||
}
|
||||
.vpp-badge[data-state="disconnected"] {
|
||||
background: var(--state-down);
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
|
||||
@@ -11,6 +11,7 @@ export type VersionInfo = {
|
||||
version: string;
|
||||
commit: string;
|
||||
date: string;
|
||||
admin_enabled: boolean;
|
||||
};
|
||||
|
||||
export type TransitionRecord = {
|
||||
@@ -38,6 +39,7 @@ export type FrontendSnapshot = {
|
||||
description?: string;
|
||||
src_ip_sticky: boolean;
|
||||
pools: PoolSnapshot[];
|
||||
state?: string; // "up" | "down" | "unknown"
|
||||
};
|
||||
|
||||
export type BackendSnapshot = {
|
||||
@@ -76,11 +78,12 @@ export type StateSnapshot = {
|
||||
backends: BackendSnapshot[];
|
||||
healthchecks: HealthCheckSnapshot[];
|
||||
vpp_info?: VPPInfoSnapshot;
|
||||
vpp_state?: string; // "connected" | "disconnected" | ""
|
||||
};
|
||||
|
||||
export type BrowserEvent = {
|
||||
maglevd: string;
|
||||
type: "log" | "backend" | "frontend" | "maglevd-status" | "resync";
|
||||
type: "log" | "backend" | "frontend" | "maglevd-status" | "vpp-status" | "resync";
|
||||
at_unix_ns: number;
|
||||
payload: unknown;
|
||||
};
|
||||
@@ -105,3 +108,7 @@ export type MaglevdStatusPayload = {
|
||||
connected: boolean;
|
||||
last_error?: string;
|
||||
};
|
||||
|
||||
export type VPPStatusPayload = {
|
||||
state: string; // "connected" | "disconnected"
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { Show, type Component } from "solid-js";
|
||||
import type { BackendSnapshot, PoolBackendSnapshot } from "../types";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
import ProbeHeartbeat from "../components/ProbeHeartbeat";
|
||||
import Flash from "../components/Flash";
|
||||
import BackendActionsMenu from "../components/BackendActionsMenu";
|
||||
import { lastTransitionAge } from "../stores/state";
|
||||
import { isAdmin } from "../stores/mode";
|
||||
|
||||
type Props = {
|
||||
maglevd: string;
|
||||
@@ -33,6 +35,11 @@ const BackendRow: Component<Props> = (props) => {
|
||||
<Flash value={props.poolBackend.effective_weight} />
|
||||
</td>
|
||||
<td class="age">{lastTransitionAge(b().last_transition)}</td>
|
||||
<Show when={isAdmin}>
|
||||
<td class="actions">
|
||||
<BackendActionsMenu maglevd={props.maglevd} backend={b().name} state={b().state} />
|
||||
</td>
|
||||
</Show>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { For, type Component } from "solid-js";
|
||||
import { For, Show, type Component } from "solid-js";
|
||||
import type { FrontendSnapshot, StateSnapshot } from "../types";
|
||||
import BackendRow from "./BackendRow";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
import Flash from "../components/Flash";
|
||||
import { isAdmin } from "../stores/mode";
|
||||
|
||||
type Props = {
|
||||
snap: StateSnapshot;
|
||||
@@ -14,7 +17,12 @@ const FrontendCard: Component<Props> = (props) => {
|
||||
return (
|
||||
<section class="frontend-card">
|
||||
<header class="frontend-header">
|
||||
<h2>{fe().name}</h2>
|
||||
<h2>
|
||||
{fe().name}
|
||||
<Flash value={fe().state ?? "unknown"}>
|
||||
<StatusBadge state={fe().state ?? "unknown"} />
|
||||
</Flash>
|
||||
</h2>
|
||||
<div class="frontend-meta">
|
||||
<span class="addr">
|
||||
{fe().address}:{fe().port}
|
||||
@@ -38,6 +46,9 @@ const FrontendCard: Component<Props> = (props) => {
|
||||
<th class="numeric">weight</th>
|
||||
<th class="numeric">effective</th>
|
||||
<th>last transition</th>
|
||||
<Show when={isAdmin}>
|
||||
<th class="actions" />
|
||||
</Show>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -24,7 +24,7 @@ const Overview: Component = () => {
|
||||
<div class="frontend-grid">
|
||||
<For each={s().frontends}>{(fe) => <FrontendCard snap={s()} frontend={fe} />}</For>
|
||||
</div>
|
||||
<VPPInfoPanel info={s().vpp_info} />
|
||||
<VPPInfoPanel info={s().vpp_info} state={s().vpp_state} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,28 +1,49 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { Show, type Component } from "solid-js";
|
||||
import Zippy from "../components/Zippy";
|
||||
import Flash from "../components/Flash";
|
||||
import type { VPPInfoSnapshot } from "../types";
|
||||
|
||||
type Props = { info?: VPPInfoSnapshot };
|
||||
type Props = {
|
||||
info?: VPPInfoSnapshot;
|
||||
state?: string; // "connected" | "disconnected" | ""
|
||||
};
|
||||
|
||||
const VPPInfoPanel: Component<Props> = (props) => {
|
||||
if (!props.info) return null;
|
||||
const i = props.info;
|
||||
const boot = i.boottime_ns ? new Date(i.boottime_ns / 1e6).toISOString() : "";
|
||||
const conn = i.connecttime_ns ? new Date(i.connecttime_ns / 1e6).toISOString() : "";
|
||||
const boot = () =>
|
||||
props.info?.boottime_ns ? new Date(props.info.boottime_ns / 1e6).toISOString() : "";
|
||||
const conn = () =>
|
||||
props.info?.connecttime_ns ? new Date(props.info.connecttime_ns / 1e6).toISOString() : "";
|
||||
const label = () => (props.state === "connected" ? "connected" : "disconnected");
|
||||
|
||||
const title = (
|
||||
<span class="zippy-title">
|
||||
VPP
|
||||
<Flash value={label()}>
|
||||
<span class="vpp-badge" data-state={label()}>
|
||||
{label()}
|
||||
</span>
|
||||
</Flash>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Zippy title="VPP information">
|
||||
<Zippy title={title}>
|
||||
<Show when={props.info} fallback={<p class="empty">No VPP information available.</p>}>
|
||||
{(i) => (
|
||||
<dl class="kv">
|
||||
<dt>version</dt>
|
||||
<dd>{i.version}</dd>
|
||||
<dd>{i().version}</dd>
|
||||
<dt>build date</dt>
|
||||
<dd>{i.build_date}</dd>
|
||||
<dd>{i().build_date}</dd>
|
||||
<dt>pid</dt>
|
||||
<dd>{i.pid}</dd>
|
||||
<dd>{i().pid}</dd>
|
||||
<dt>booted</dt>
|
||||
<dd>{boot}</dd>
|
||||
<dd>{boot()}</dd>
|
||||
<dt>connected</dt>
|
||||
<dd>{conn}</dd>
|
||||
<dd>{conn()}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</Show>
|
||||
</Zippy>
|
||||
);
|
||||
};
|
||||
|
||||
26
debian/build-deb.sh
vendored
26
debian/build-deb.sh
vendored
@@ -1,15 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Build a vpp-maglev Debian package for one architecture.
|
||||
# Usage: build-deb.sh <amd64|arm64> <version> <commit>
|
||||
# Usage: build-deb.sh <amd64|arm64> <version>
|
||||
#
|
||||
# The commit hash is baked into the binaries at link time via -ldflags
|
||||
# in the Makefile, so `maglevd --version` / `maglevc --version` /
|
||||
# `maglev-frontend --version` are the source of truth for "which
|
||||
# build". The .deb itself carries only the release version.
|
||||
set -euo pipefail
|
||||
|
||||
ARCH="${1:?usage: build-deb.sh <amd64|arm64> <version> <commit>}"
|
||||
VERSION="${2:?usage: build-deb.sh <amd64|arm64> <version> <commit>}"
|
||||
COMMIT="${3:?usage: build-deb.sh <amd64|arm64> <version> <commit>}"
|
||||
ARCH="${1:?usage: build-deb.sh <amd64|arm64> <version>}"
|
||||
VERSION="${2:?usage: build-deb.sh <amd64|arm64> <version>}"
|
||||
|
||||
FULL_VERSION="${VERSION}~${COMMIT}"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PKG="vpp-maglev_${FULL_VERSION}_${ARCH}"
|
||||
PKG="vpp-maglev_${VERSION}_${ARCH}"
|
||||
STAGING="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGING"' EXIT
|
||||
|
||||
@@ -28,13 +31,16 @@ install -d "$STAGING/DEBIAN"
|
||||
# Binaries
|
||||
install -m 755 "$REPO_ROOT/build/${ARCH}/maglevd" "$STAGING/usr/sbin/maglevd"
|
||||
install -m 755 "$REPO_ROOT/build/${ARCH}/maglevc" "$STAGING/usr/bin/maglevc"
|
||||
install -m 755 "$REPO_ROOT/build/${ARCH}/maglev-frontend" "$STAGING/usr/bin/maglev-frontend"
|
||||
|
||||
# Man pages
|
||||
gzip -9 -c "$REPO_ROOT/docs/maglevd.8" > "$STAGING/usr/share/man/man8/maglevd.8.gz"
|
||||
gzip -9 -c "$REPO_ROOT/docs/maglevc.1" > "$STAGING/usr/share/man/man1/maglevc.1.gz"
|
||||
gzip -9 -c "$REPO_ROOT/docs/maglev-frontend.8" > "$STAGING/usr/share/man/man8/maglev-frontend.8.gz"
|
||||
|
||||
# Systemd unit
|
||||
install -m 644 "$REPO_ROOT/debian/vpp-maglevd.service" "$STAGING/lib/systemd/system/vpp-maglevd.service"
|
||||
# Systemd units
|
||||
install -m 644 "$REPO_ROOT/debian/vpp-maglev.service" "$STAGING/lib/systemd/system/vpp-maglev.service"
|
||||
install -m 644 "$REPO_ROOT/debian/vpp-maglev-frontend.service" "$STAGING/lib/systemd/system/vpp-maglev-frontend.service"
|
||||
|
||||
# /etc/default/vpp-maglev (conffile — dpkg won't overwrite on upgrade)
|
||||
install -m 644 "$REPO_ROOT/debian/default.vpp-maglev" "$STAGING/etc/default/vpp-maglev"
|
||||
@@ -42,8 +48,8 @@ install -m 644 "$REPO_ROOT/debian/default.vpp-maglev" "$STAGING/etc/default/vpp-
|
||||
# /etc/vpp-maglev/maglev.yaml (conffile)
|
||||
install -m 644 "$REPO_ROOT/debian/maglev.yaml" "$STAGING/etc/vpp-maglev/maglev.yaml"
|
||||
|
||||
# DEBIAN/control (version field uses full_version including commit)
|
||||
sed "s/@VERSION@/${FULL_VERSION}/;s/@ARCH@/${ARCH}/" \
|
||||
# DEBIAN/control
|
||||
sed "s/@VERSION@/${VERSION}/;s/@ARCH@/${ARCH}/" \
|
||||
"$REPO_ROOT/debian/control.in" > "$STAGING/DEBIAN/control"
|
||||
|
||||
# DEBIAN/conffiles, postinst, prerm, postrm
|
||||
|
||||
7
debian/control.in
vendored
7
debian/control.in
vendored
@@ -5,10 +5,15 @@ Maintainer: Pim van Pelt <pim@ipng.ch>
|
||||
Section: net
|
||||
Priority: optional
|
||||
Depends: systemd, adduser
|
||||
Description: Maglev health-checker daemon and CLI client
|
||||
Description: Maglev health-checker daemon, CLI client, and web frontend
|
||||
maglevd monitors backends (HTTP, TCP, ICMP) with a rise/fall counter
|
||||
model and exposes their aggregated state over a gRPC API. Configuration
|
||||
is loaded from a YAML file and supports live reload via SIGHUP.
|
||||
.
|
||||
maglevc is an interactive CLI client for maglevd with tab completion,
|
||||
inline help, and one-shot mode for scripting.
|
||||
.
|
||||
maglev-frontend is an optional web dashboard that fans one or more
|
||||
maglevd gRPC streams out to browsers over Server-Sent Events. It is
|
||||
installed but not enabled by default; enable with:
|
||||
systemctl enable --now vpp-maglev-frontend
|
||||
|
||||
22
debian/default.vpp-maglev
vendored
22
debian/default.vpp-maglev
vendored
@@ -1,6 +1,12 @@
|
||||
# Default settings for maglevd.
|
||||
# This file is sourced by /lib/systemd/system/vpp-maglevd.service.
|
||||
# After editing, run: systemctl restart vpp-maglevd
|
||||
# Default settings for the vpp-maglev package.
|
||||
# This file is sourced by:
|
||||
# /lib/systemd/system/vpp-maglev.service
|
||||
# /lib/systemd/system/vpp-maglev-frontend.service
|
||||
# After editing, restart the relevant unit(s):
|
||||
# systemctl restart vpp-maglev
|
||||
# systemctl restart vpp-maglev-frontend
|
||||
|
||||
# ---- maglevd ---------------------------------------------------------------
|
||||
|
||||
# Path to the YAML configuration file.
|
||||
MAGLEV_CONFIG=/etc/vpp-maglev/maglev.yaml
|
||||
@@ -10,3 +16,13 @@ MAGLEV_CONFIG=/etc/vpp-maglev/maglev.yaml
|
||||
|
||||
# Log level: debug, info, warn, error (default: info)
|
||||
#MAGLEV_LOG_LEVEL=info
|
||||
|
||||
# ---- maglev-frontend -------------------------------------------------------
|
||||
# The web dashboard is installed but not enabled by default. Enable with
|
||||
# systemctl enable --now vpp-maglev-frontend
|
||||
# after reviewing the arguments below.
|
||||
|
||||
# Command-line arguments passed to /usr/bin/maglev-frontend. At minimum
|
||||
# -server is required (comma-separated list of maglevd gRPC addresses).
|
||||
# -listen controls the HTTP bind address. See maglev-frontend(8).
|
||||
MAGLEV_FRONTEND_ARGS="-server localhost:9090 -listen=:8080"
|
||||
|
||||
15
debian/postinst
vendored
15
debian/postinst
vendored
@@ -16,7 +16,20 @@ case "$1" in
|
||||
adduser --quiet maglevd vpp || true
|
||||
fi
|
||||
|
||||
# Upgrade path: older versions of this package shipped the
|
||||
# daemon unit as vpp-maglevd.service. Stop and disable it so
|
||||
# the rename to vpp-maglev.service takes effect cleanly; the
|
||||
# old unit file itself is removed automatically because the
|
||||
# new package no longer owns it.
|
||||
if systemctl list-unit-files vpp-maglevd.service > /dev/null 2>&1; then
|
||||
systemctl stop vpp-maglevd.service || true
|
||||
systemctl disable vpp-maglevd.service || true
|
||||
fi
|
||||
|
||||
systemctl daemon-reload || true
|
||||
systemctl enable vpp-maglevd.service || true
|
||||
systemctl enable vpp-maglev.service || true
|
||||
# vpp-maglev-frontend is intentionally NOT enabled here: the
|
||||
# operator decides whether to expose the web dashboard. Enable
|
||||
# with: systemctl enable --now vpp-maglev-frontend
|
||||
;;
|
||||
esac
|
||||
|
||||
6
debian/prerm
vendored
6
debian/prerm
vendored
@@ -2,7 +2,9 @@
|
||||
set -e
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
systemctl stop vpp-maglevd.service || true
|
||||
systemctl disable vpp-maglevd.service || true
|
||||
systemctl stop vpp-maglev.service || true
|
||||
systemctl disable vpp-maglev.service || true
|
||||
systemctl stop vpp-maglev-frontend.service || true
|
||||
systemctl disable vpp-maglev-frontend.service || true
|
||||
;;
|
||||
esac
|
||||
|
||||
23
debian/vpp-maglev-frontend.service
vendored
Normal file
23
debian/vpp-maglev-frontend.service
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Maglev web frontend dashboard
|
||||
Documentation=man:maglev-frontend(8)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=maglevd
|
||||
Group=maglevd
|
||||
EnvironmentFile=/etc/default/vpp-maglev
|
||||
ExecStart=/usr/bin/maglev-frontend $MAGLEV_FRONTEND_ARGS
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
Type=simple
|
||||
|
||||
# Read-only presentation layer — needs no capabilities.
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
178
docs/maglev-frontend.8
Normal file
178
docs/maglev-frontend.8
Normal file
@@ -0,0 +1,178 @@
|
||||
.TH MAGLEV\-FRONTEND 8 "April 2026" "vpp\-maglev" "System Administration"
|
||||
.SH NAME
|
||||
maglev\-frontend \- web dashboard for one or more running maglevd instances
|
||||
.SH SYNOPSIS
|
||||
.B maglev\-frontend
|
||||
\fB\-server\fR \fIaddr\fR[,\fIaddr\fR...]
|
||||
[\fB\-listen\fR \fIaddr\fR]
|
||||
[\fB\-log\-level\fR \fIlevel\fR]
|
||||
[\fB\-version\fR]
|
||||
.SH DESCRIPTION
|
||||
.B maglev\-frontend
|
||||
is a single\-binary web dashboard that connects to one or more running
|
||||
.BR maglevd (8)
|
||||
instances over gRPC and renders a live view of frontends, backends,
|
||||
health checks, and VPP load\-balancer state. The SolidJS SPA is
|
||||
embedded into the Go binary via
|
||||
.BR embed.FS ,
|
||||
so no runtime file dependencies are required; pointing the binary at
|
||||
one or more maglevds with
|
||||
.B \-server
|
||||
is enough to serve the dashboard.
|
||||
.PP
|
||||
For each configured maglevd,
|
||||
.B maglev\-frontend
|
||||
maintains:
|
||||
.IP \(bu 2
|
||||
A long\-lived
|
||||
.B WatchEvents
|
||||
gRPC stream subscribed at
|
||||
.BR log_level=debug ,
|
||||
which delivers backend transitions, frontend transitions, per\-probe
|
||||
log records (used to drive the live probe heartbeat), and per\-mutation
|
||||
VPP LB sync records so the UI reflects every dataplane change in real
|
||||
time.
|
||||
.IP \(bu 2
|
||||
A 30\-second refresh loop that re\-fetches
|
||||
.BR ListFrontends / GetFrontend ,
|
||||
.BR ListBackends / GetBackend ,
|
||||
.BR ListHealthChecks / GetHealthCheck ,
|
||||
and
|
||||
.B GetVPPInfo
|
||||
as a safety net against missed events.
|
||||
.IP \(bu 2
|
||||
A 5\-second health probe that surfaces maglevd connection drops
|
||||
quickly and flips the scope\-selector indicator dot red.
|
||||
.PP
|
||||
Browsers connect to
|
||||
.B maglev\-frontend
|
||||
over HTTP. State is hydrated once via REST and then kept live via a
|
||||
Server\-Sent Events stream. Short SSE disconnects (nginx idle timeout,
|
||||
wifi flap, laptop wake) are handled silently via a 30\-second replay
|
||||
ring buffer; longer outages fall through to a full refetch. The SPA
|
||||
is stateless on reload so refreshing the page at any time returns a
|
||||
consistent view.
|
||||
.SH OPTIONS
|
||||
Each flag may also be supplied via an environment variable (shown in
|
||||
parentheses); the flag takes precedence.
|
||||
.TP
|
||||
.BI \-server " addr[,addr...]"
|
||||
Comma\-separated list of maglevd gRPC addresses. Required. Each
|
||||
entry is in
|
||||
.I host:port
|
||||
form; a short display name is derived from the hostname label (for
|
||||
IP literals the full address is used).
|
||||
.RI "(env: " MAGLEV_SERVERS )
|
||||
.TP
|
||||
.BI \-listen " addr"
|
||||
HTTP bind address for the dashboard.
|
||||
.RI "(default: " :8080 "; env: " MAGLEV_LISTEN )
|
||||
.TP
|
||||
.BI \-log\-level " level"
|
||||
Structured\-log verbosity:
|
||||
.BR debug ,
|
||||
.BR info ,
|
||||
.BR warn ,
|
||||
or
|
||||
.BR error .
|
||||
Affects
|
||||
.B maglev\-frontend 's
|
||||
own logs, not the log level it subscribes to on the upstream maglevd
|
||||
(which is always
|
||||
.BR debug
|
||||
so the probe heartbeat can animate).
|
||||
.RI "(default: " info "; env: " MAGLEV_LOG_LEVEL )
|
||||
.TP
|
||||
.B \-version
|
||||
Print version, commit hash, and build date, then exit.
|
||||
.SH HTTP ENDPOINTS
|
||||
.TP
|
||||
.I /view/
|
||||
Static SPA (HTML, JS, CSS, assets).
|
||||
.TP
|
||||
.I /view/api/maglevds
|
||||
JSON array describing the configured maglevds and their current
|
||||
connection status.
|
||||
.TP
|
||||
.I /view/api/state
|
||||
Full JSON state snapshot for every maglevd.
|
||||
.TP
|
||||
.I /view/api/state/{name}
|
||||
Full JSON state snapshot for a single maglevd.
|
||||
.TP
|
||||
.I /view/api/version
|
||||
Build version, commit hash, and build date.
|
||||
.TP
|
||||
.I /view/api/events
|
||||
Server\-Sent Events stream. Long\-lived HTTP/1.1 chunked response
|
||||
fanning out log, backend, frontend, maglevd\-status, and vpp\-status
|
||||
events to every connected browser. Supports
|
||||
.B Last\-Event\-ID
|
||||
replay from a 30\-second / 2000\-event ring buffer.
|
||||
.TP
|
||||
.I /healthz
|
||||
Liveness endpoint; returns 200 if the HTTP server is up.
|
||||
.TP
|
||||
.I /admin/
|
||||
Placeholder for a future basic\-auth mutation surface. Currently
|
||||
returns
|
||||
.B 501 Not Implemented .
|
||||
.SH REVERSE PROXY NOTES
|
||||
The SSE stream has a handful of operational requirements that every
|
||||
reverse proxy must satisfy:
|
||||
.IP \(bu 2
|
||||
Disable buffering on the events endpoint. Nginx honours
|
||||
.B X\-Accel\-Buffering: no
|
||||
(sent by
|
||||
.BR maglev\-frontend )
|
||||
but a global
|
||||
.B proxy_buffering off;
|
||||
in the server block is the more robust answer.
|
||||
.IP \(bu 2
|
||||
Raise
|
||||
.B proxy_read_timeout
|
||||
to at least
|
||||
.BR 300s
|
||||
so the stream isn't torn down between the 15\-second
|
||||
.B :\ ping
|
||||
heartbeats that
|
||||
.B maglev\-frontend
|
||||
sends.
|
||||
.IP \(bu 2
|
||||
Do not wrap the events endpoint in a gzip/brotli middleware — response
|
||||
compression buffers until its window fills and destroys the live\-stream
|
||||
property.
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B MAGLEV_SERVERS
|
||||
Default value of
|
||||
.BR \-server .
|
||||
.TP
|
||||
.B MAGLEV_LISTEN
|
||||
Default value of
|
||||
.BR \-listen .
|
||||
.TP
|
||||
.B MAGLEV_LOG_LEVEL
|
||||
Default value of
|
||||
.BR \-log\-level .
|
||||
.SH FILES
|
||||
.TP
|
||||
.I /etc/default/vpp-maglev
|
||||
Environment file sourced by the systemd unit before starting
|
||||
.BR maglev\-frontend .
|
||||
The same file is shared with
|
||||
.BR maglevd (8);
|
||||
the
|
||||
.B MAGLEV_FRONTEND_ARGS
|
||||
variable there is passed on the command line to
|
||||
.B maglev\-frontend .
|
||||
.SH SEE ALSO
|
||||
.BR maglevd (8),
|
||||
.BR maglevc (1)
|
||||
.SH "FULL DOCUMENTATION"
|
||||
.PP
|
||||
.RS
|
||||
https://git.ipng.ch/ipng/vpp-maglev/docs/user-guide.md
|
||||
.RE
|
||||
.SH AUTHOR
|
||||
Pim van Pelt <pim@ipng.ch>
|
||||
Reference in New Issue
Block a user