Rename maglev-frontend → maglevd-frontend; v0.9.1; API RX/TX pulse

Rename the web dashboard binary to maglevd-frontend and move it to
/usr/sbin (it's a daemon and belongs with maglevd). The systemd unit
name stays vpp-maglev-frontend.service since that prefix is the
package name. Manpage, README, user-guide, and debian packaging all
updated in lockstep; bump to 0.9.1 for the first real release.

All frontend env vars are now prefixed MAGLEV_FRONTEND_ so a single
/etc/default/vpp-maglev can be shared with maglevd without collisions.
Every flag has an env equivalent for Docker use. MAGLEV_FRONTEND_USER
and MAGLEV_FRONTEND_PASSWORD still gate the /admin surface.

VPPInfoPanel now pulses "API: ↑↓" indicators in the zippy title
whenever a vpp-api-send / vpp-api-recv log event arrives on the SSE
stream for the scoped maglevd — 250ms blue flash, re-triggerable,
with the two arrows tightly kerned via negative letter-spacing.
This commit is contained in:
2026-04-13 00:13:47 +02:00
parent 1191b3d994
commit 35643fd774
20 changed files with 380 additions and 83 deletions

View File

@@ -106,7 +106,7 @@ func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broke
adminAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleAdminAPI(w, r, byName)
})
realm := "maglev-frontend admin"
realm := "maglevd-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))

View File

@@ -26,14 +26,18 @@ func main() {
}
func run() error {
// All env vars are prefixed with MAGLEV_FRONTEND_ so a single
// /etc/default/vpp-maglev (or a Docker env file) can be shared
// with maglevd without its MAGLEV_LOG_LEVEL / MAGLEV_GRPC_ADDR /
// etc. leaking into this process's config.
printVersion := flag.Bool("version", false, "print version and exit")
servers := stringFlag("server", "", "MAGLEV_SERVERS", "comma-separated maglevd gRPC addresses (required)")
listen := stringFlag("listen", ":8080", "MAGLEV_LISTEN", "HTTP listen address")
logLevel := stringFlag("log-level", "info", "MAGLEV_LOG_LEVEL", "log verbosity (debug|info|warn|error)")
servers := stringFlag("server", "", "MAGLEV_FRONTEND_SERVERS", "comma-separated maglevd gRPC addresses (required)")
listen := stringFlag("listen", ":8080", "MAGLEV_FRONTEND_LISTEN", "HTTP listen address")
logLevel := stringFlag("log-level", "info", "MAGLEV_FRONTEND_LOG_LEVEL", "log verbosity (debug|info|warn|error)")
flag.Parse()
if *printVersion {
fmt.Printf("maglev-frontend %s (commit %s, built %s)\n",
fmt.Printf("maglevd-frontend %s (commit %s, built %s)\n",
buildinfo.Version(), buildinfo.Commit(), buildinfo.Date())
return nil
}

View File

@@ -25,7 +25,7 @@ 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 maglevd-frontend binary
// plus runtime capability flags the SPA needs to know at mount time.
type VersionInfo struct {
Version string `json:"version"`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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-C-XMkBf5.js"></script>
<link rel="stylesheet" crossorigin href="/view/assets/index-CxDuAfMR.css">
<script type="module" crossorigin src="/view/assets/index-DjixLt11.js"></script>
<link rel="stylesheet" crossorigin href="/view/assets/index-CExoCDXh.css">
</head>
<body>
<div id="root"></div>

View File

@@ -676,6 +676,45 @@
background: var(--state-down);
}
.vpp-io {
display: inline-flex;
align-items: center;
gap: 0;
margin-left: 4px;
font-size: 13px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.15em;
padding-right: 0.15em;
}
.vpp-io-label {
color: var(--fg-muted);
font-weight: 500;
letter-spacing: normal;
margin-right: 4px;
}
.vpp-tx,
.vpp-rx {
display: inline-block;
color: var(--fg-muted);
opacity: 0.25;
transition:
color 120ms ease-out,
opacity 120ms ease-out,
transform 120ms ease-out;
transform: translateY(0);
}
.vpp-tx.lit {
color: var(--accent);
opacity: 1;
transform: translateY(-1px);
}
.vpp-rx.lit {
color: var(--accent);
opacity: 1;
transform: translateY(1px);
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;

View File

@@ -24,7 +24,7 @@ const Overview: Component = () => {
<div class="frontend-list">
<For each={s().frontends}>{(fe) => <FrontendCard snap={s()} frontend={fe} />}</For>
</div>
<VPPInfoPanel info={s().vpp_info} state={s().vpp_state} />
<VPPInfoPanel name={s().maglevd.name} info={s().vpp_info} state={s().vpp_state} />
</>
)}
</Show>

View File

@@ -1,9 +1,11 @@
import { Show, type Component } from "solid-js";
import { Show, createEffect, createSignal, onCleanup, type Component } from "solid-js";
import Zippy from "../components/Zippy";
import Flash from "../components/Flash";
import type { VPPInfoSnapshot } from "../types";
import { events } from "../stores/events";
import type { LogEventPayload, VPPInfoSnapshot } from "../types";
type Props = {
name: string;
info?: VPPInfoSnapshot;
state?: string; // "connected" | "disconnected" | ""
};
@@ -15,6 +17,45 @@ const VPPInfoPanel: Component<Props> = (props) => {
props.info?.connecttime_ns ? new Date(props.info.connecttime_ns / 1e6).toISOString() : "";
const label = () => (props.state === "connected" ? "connected" : "disconnected");
// RX/TX indicators: pulse for 100ms whenever a vpp-api-send / vpp-api-recv
// debug log arrives for this maglevd. The upstream events() signal is
// updated one push at a time, so looking at the newest entry in an effect
// sees every event.
const [tx, setTx] = createSignal(false);
const [rx, setRx] = createSignal(false);
let txTimer: number | undefined;
let rxTimer: number | undefined;
const flashTx = () => {
setTx(true);
if (txTimer) clearTimeout(txTimer);
txTimer = window.setTimeout(() => {
setTx(false);
txTimer = undefined;
}, 250);
};
const flashRx = () => {
setRx(true);
if (rxTimer) clearTimeout(rxTimer);
rxTimer = window.setTimeout(() => {
setRx(false);
rxTimer = undefined;
}, 250);
};
createEffect(() => {
const evs = events();
if (!evs.length) return;
const ev = evs[evs.length - 1];
if (ev.maglevd !== props.name || ev.type !== "log") return;
const msg = (ev.payload as LogEventPayload | undefined)?.msg;
if (!msg) return;
if (msg.startsWith("vpp-api-send")) flashTx();
else if (msg.startsWith("vpp-api-recv")) flashRx();
});
onCleanup(() => {
if (txTimer) clearTimeout(txTimer);
if (rxTimer) clearTimeout(rxTimer);
});
const title = (
<span class="zippy-title">
VPP
@@ -23,6 +64,15 @@ const VPPInfoPanel: Component<Props> = (props) => {
{label()}
</span>
</Flash>
<span class="vpp-io" aria-hidden="true">
<span class="vpp-io-label">API:</span>
<span class="vpp-tx" classList={{ lit: tx() }}>
</span>
<span class="vpp-rx" classList={{ lit: rx() }}>
</span>
</span>
</span>
);