fleet-manager: checkpoint 6 — the admin SPA (fleet command deck)
The themed single-page admin UI (serving/fleet-manager/ui/), embedded into the binary and served same-origin over the admin plane. - Svelte 5 + Tailwind 4 + Vite; a sibling of serving/kiosk-app. Theme = LYSANDRA 'restricted-violet' (high-security evolution of the kiosk's flight-deck DNA): amber deck, VIOLET reserved for privileged/destructive + auth surfaces, teal telemetry, red alert, clearance chrome (corner-reticle panels, scanlines, mono clearance labels, secure-link status bar). - Passkey gate: Login (usernameless discoverable WebAuthn) + Enrol (from the register-admin one-time link) via navigator.credentials, with base64url<->buffer helpers and the go-webauthn response shapes. - Three pillars over the JSON API: Monitor (fleet roster + version-drift + health + per-host detail with bespoke SVG telemetry charts), Authorise (pending queue: fingerprint cross-check, approve+bind in violet, deny), Lifecycle (desired-state form; drain/reboot confirm-gated). Session-aware fetch client (cookie + CSRF). - go:embed behind the build tag (spa_embed.go / spa_stub.go) so the default go build/test need no dist/; mounted at / (api/auth/healthz win). Verified: npm build clean (71KB JS / 33KB CSS + self-hosted fonts); default + -tags embedspa go builds; smoke test serves index + assets + API through the SPA origin. Against an insecure+seeded backend the deck renders passkey-free for theme work. dist/ + node_modules gitignored.
This commit is contained in:
@@ -170,4 +170,9 @@ curl -s localhost:9500/api/hosts | jq '.hosts[] | {name:.identity.starship_name,
|
|||||||
CSRF middleware gating `/api/*`, admin TLS switched to server-auth with a
|
CSRF middleware gating `/api/*`, admin TLS switched to server-auth with a
|
||||||
hot-reloading cert (for LE DNS-01 renewals). Tests: enrol token, session
|
hot-reloading cert (for LE DNS-01 renewals). Tests: enrol token, session
|
||||||
middleware, cred-store persistence. Live passkey ceremony is a bench item.
|
middleware, cred-store persistence. Live passkey ceremony is a bench item.
|
||||||
- Next: themed SPA (ckpt 6) → Docker/compose + PKI/RP + docs (ckpt 7).
|
- **Checkpoint 6 (done):** the **SPA** ([`ui/`](ui/)) — Svelte 5 + Tailwind 4,
|
||||||
|
LYSANDRA restricted-violet theme, passkey login/enrol + Monitor/Authorise/
|
||||||
|
Lifecycle + bespoke SVG charts. Embedded via `go:embed` behind the `embedspa`
|
||||||
|
build tag; served same-origin over the admin plane. Smoke-tested end-to-end
|
||||||
|
(index + assets + API through the SPA origin).
|
||||||
|
- Next: Docker/compose + PKI/RP/ACME + docs (ckpt 7).
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ func main() {
|
|||||||
apiMux := http.NewServeMux()
|
apiMux := http.NewServeMux()
|
||||||
s.apiRoutes(apiMux)
|
s.apiRoutes(apiMux)
|
||||||
root.Handle("/api/", s.requireSession(apiMux))
|
root.Handle("/api/", s.requireSession(apiMux))
|
||||||
|
// SPA at the root (catch-all; the more specific /api, /auth, /healthz win).
|
||||||
|
// Embedded only in `-tags embedspa` builds; a stub otherwise (see spa_*.go).
|
||||||
|
root.Handle("/", s.spaHandler())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.adminAddr,
|
Addr: cfg.adminAddr,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//go:build embedspa
|
||||||
|
|
||||||
|
// spa_embed.go — serves the Vite-built SPA embedded into the binary. Built only
|
||||||
|
// with `-tags embedspa` (after `npm --prefix ui ci && npm --prefix ui run build`),
|
||||||
|
// so the default `go build`/`go test` don't require ui/dist (a build artifact).
|
||||||
|
// The Dockerfile builds the SPA then compiles with this tag.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:ui/dist
|
||||||
|
var spaFiles embed.FS
|
||||||
|
|
||||||
|
func (s *server) spaHandler() http.Handler {
|
||||||
|
sub, err := fs.Sub(spaFiles, "ui/dist")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
files := http.FileServer(http.FS(sub))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if p == "" {
|
||||||
|
p = "index.html"
|
||||||
|
}
|
||||||
|
// Real asset -> serve it; anything else -> SPA entry (hash routing).
|
||||||
|
if _, err := fs.Stat(sub, p); err != nil {
|
||||||
|
http.ServeFileFS(w, r, sub, "index.html")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build !embedspa
|
||||||
|
|
||||||
|
// spa_stub.go — the default build has no SPA embedded (so `go build`/`go test`
|
||||||
|
// don't need ui/dist). Build with `-tags embedspa` after building the SPA to
|
||||||
|
// serve the real UI (see spa_embed.go).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func (s *server) spaHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("fleet-manager: SPA not embedded in this build.\n" +
|
||||||
|
"Build with: (cd ui && npm ci && npm run build) then `go build -tags embedspa`.\n"))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# fleet-manager UI — the fleet command deck
|
||||||
|
|
||||||
|
The admin SPA for `fleet-manager`. **Svelte 5 + Tailwind 4 + Vite**, themed
|
||||||
|
**LYSANDRA / restricted-violet** (a high-security evolution of the kiosk-app's
|
||||||
|
LYSANDRA flight-deck: amber deck, **violet reserved for privileged/destructive +
|
||||||
|
auth surfaces**, teal telemetry, red alert, clearance chrome). A sibling of
|
||||||
|
`serving/kiosk-app`, kept in-repo for now (may graduate to its own submodule).
|
||||||
|
|
||||||
|
Three pillars over the manager's JSON API, plus the passkey gate:
|
||||||
|
|
||||||
|
- **Login / Enrol** — WebAuthn ceremonies (`navigator.credentials`). Enrol is
|
||||||
|
reached via the one-time link from `fleet-manager register-admin`.
|
||||||
|
- **Monitor** — fleet roster (online / version-drift / health) + per-host detail
|
||||||
|
with bespoke SVG telemetry charts (from `/api/hosts/{serial}/metrics`).
|
||||||
|
- **Authorise** — pending-enrolment queue: cross-check the fingerprint, approve
|
||||||
|
(violet) + bind, or deny.
|
||||||
|
- **Lifecycle** — per-host desired-state (version pin, kiosk URL, reboot, drain);
|
||||||
|
destructive changes are confirm-gated.
|
||||||
|
|
||||||
|
## Build / embed
|
||||||
|
|
||||||
|
The built SPA is embedded into the `fleet-manager` binary via `go:embed` and
|
||||||
|
served same-origin over the admin plane.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build # -> dist/ (gitignored)
|
||||||
|
cd .. && go build -tags embedspa # embeds dist/ into the binary
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `go build` (no tag) ships a stub instead, so Go builds/tests don't
|
||||||
|
require `dist/`. The Dockerfile does the two-stage build (npm → dist → go build
|
||||||
|
`-tags embedspa`).
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Vite on :5173, proxies /api + /auth to :9500
|
||||||
|
```
|
||||||
|
|
||||||
|
Point it at a locally-running manager. Against an **insecure+seeded** manager
|
||||||
|
(`FLEET_MGR_ADMIN_INSECURE=true FLEET_MGR_SEED=true`), `/api/whoami` returns
|
||||||
|
`dev-insecure`, so the SPA renders the pillars **without a passkey** — handy for
|
||||||
|
theme/layout work. Passkey login/enrol need the manager in secure mode with a
|
||||||
|
valid RP origin + a WebAuthn authenticator.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Fleet Command</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "fleet-manager-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Fleet command deck — the fleet-manager admin SPA (LYSANDRA restricted-violet, high-security). Passkey login + monitor/authorise/lifecycle over the manager's JSON API.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource/chakra-petch": "^5.2.7",
|
||||||
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"svelte": "^5.19.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { session, refreshSession } from './lib/session.svelte.js'
|
||||||
|
import { router } from './lib/router.svelte.js'
|
||||||
|
import Navbar from './components/Navbar.svelte'
|
||||||
|
import StatusBar from './components/StatusBar.svelte'
|
||||||
|
import Login from './pages/Login.svelte'
|
||||||
|
import Enrol from './pages/Enrol.svelte'
|
||||||
|
import Monitor from './pages/Monitor.svelte'
|
||||||
|
import Authorise from './pages/Authorise.svelte'
|
||||||
|
import Lifecycle from './pages/Lifecycle.svelte'
|
||||||
|
|
||||||
|
const pages = { monitor: Monitor, authorise: Authorise, lifecycle: Lifecycle }
|
||||||
|
let Current = $derived(pages[router.path] || Monitor)
|
||||||
|
|
||||||
|
onMount(refreshSession)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if router.path === 'enrol'}
|
||||||
|
<Enrol />
|
||||||
|
{:else if !session.ready}
|
||||||
|
<div class="relative z-10 flex min-h-full items-center justify-center text-muted">
|
||||||
|
<span class="cursor">initialising fleet command</span>
|
||||||
|
</div>
|
||||||
|
{:else if !session.admin}
|
||||||
|
<Login />
|
||||||
|
{:else}
|
||||||
|
<div class="relative z-10 flex h-full flex-col">
|
||||||
|
<Navbar />
|
||||||
|
<StatusBar />
|
||||||
|
<main class="flex-1 overflow-y-auto p-4 md:p-6"><Current /></main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
FLEET COMMAND — "LYSANDRA / Restricted Violet" high-security design system.
|
||||||
|
Continues the kiosk's LYSANDRA flight-deck DNA (warm amber deck on a cold
|
||||||
|
deep-blue void, Chakra Petch + IBM Plex) but ELEVATED for a privileged control
|
||||||
|
plane: deeper/colder void, higher contrast, clearance chrome, and a reserved
|
||||||
|
VIOLET clearance hue used ONLY on privileged/destructive/auth surfaces — so
|
||||||
|
"high-consequence" is legible at a glance. Amber = the default deck; teal =
|
||||||
|
telemetry; red = alert; violet = restricted.
|
||||||
|
=========================================================================== */
|
||||||
|
@theme {
|
||||||
|
/* deep blue-black void — colder + darker than the kiosk (#070A12) */
|
||||||
|
--color-void: #05070E;
|
||||||
|
--color-void2: #080D18;
|
||||||
|
/* raised panel surfaces */
|
||||||
|
--color-hull: #0A1220;
|
||||||
|
--color-hull2: #0F1A2E;
|
||||||
|
|
||||||
|
/* signal palette */
|
||||||
|
--color-amber: #FFB23E; /* PRIMARY deck — data, nominal, navigation */
|
||||||
|
--color-amber-soft: #FFCE86;
|
||||||
|
--color-teal: #43E6C9; /* SECONDARY — telemetry / nominal data */
|
||||||
|
--color-violet: #8B7CFF; /* RESTRICTED — privileged + destructive + auth ONLY */
|
||||||
|
--color-violet-soft:#B3A9FF;
|
||||||
|
--color-green: #3DE07A; /* secure / verified / positive */
|
||||||
|
--color-red: #FF3B2F; /* alert / out-of-range */
|
||||||
|
--color-amber-hot: #FF8A3D; /* caution band */
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--color-ink: #E9EEF6; /* primary text (warm off-white) */
|
||||||
|
--color-muted: #7E8DA3; /* secondary text, labels */
|
||||||
|
--color-faint: #46566E; /* tertiary, timestamps, meta */
|
||||||
|
|
||||||
|
/* hairlines */
|
||||||
|
--color-line: #223146; /* structural */
|
||||||
|
--color-line2: #35507A; /* interactive edges */
|
||||||
|
--color-vline: #3B3170; /* violet-tinted edge for restricted panels */
|
||||||
|
|
||||||
|
/* type roles */
|
||||||
|
--font-display: "Chakra Petch", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root { color-scheme: dark; letter-spacing: 0.012em; }
|
||||||
|
html, body, #app { height: 100%; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-void);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ambient: cold amber wash top-right, a faint violet clearance wash bottom-left,
|
||||||
|
a fine scanline grid + sparse stars — the "secure ops" texture. */
|
||||||
|
#app::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(55% 45% at 88% 8%, rgba(255, 178, 62, 0.06), transparent 70%),
|
||||||
|
radial-gradient(60% 55% at 8% 96%, rgba(139, 124, 255, 0.07), transparent 70%),
|
||||||
|
repeating-linear-gradient(0deg, rgba(120,160,205,0.024) 0 1px, transparent 1px 3px),
|
||||||
|
radial-gradient(1px 1px at 21% 22%, rgba(233,238,246,0.35), transparent),
|
||||||
|
radial-gradient(1px 1px at 68% 15%, rgba(233,238,246,0.28), transparent),
|
||||||
|
radial-gradient(1.5px 1.5px at 83% 66%, rgba(233,238,246,0.30), transparent),
|
||||||
|
radial-gradient(1px 1px at 12% 58%, rgba(233,238,246,0.24), transparent),
|
||||||
|
radial-gradient(1px 1px at 44% 78%, rgba(233,238,246,0.22), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- clearance chrome utilities ---------------------------------------- */
|
||||||
|
|
||||||
|
/* Panel with corner reticles — the flight-deck instrument frame. */
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(180deg, var(--color-hull2), var(--color-hull));
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.panel::before, .panel::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 9px; height: 9px;
|
||||||
|
border: 1px solid var(--color-line2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.panel::before { top: -1px; left: -1px; border-right: 0; border-bottom: 0; }
|
||||||
|
.panel::after { bottom: -1px; right: -1px; border-left: 0; border-top: 0; }
|
||||||
|
|
||||||
|
/* Restricted panel — violet-tinted edge for privileged surfaces. */
|
||||||
|
.panel-restricted { border-color: var(--color-vline); }
|
||||||
|
.panel-restricted::before, .panel-restricted::after { border-color: var(--color-violet); }
|
||||||
|
|
||||||
|
/* clearance label — mono, spaced, faint. */
|
||||||
|
.tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* status pills */
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
font-size: 0.66rem; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.5rem; border-radius: 999px; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.pill-ok { color: var(--color-green); border-color: color-mix(in oklab, var(--color-green) 40%, transparent); background: color-mix(in oklab, var(--color-green) 10%, transparent); }
|
||||||
|
.pill-warn { color: var(--color-amber-hot); border-color: color-mix(in oklab, var(--color-amber-hot) 40%, transparent); background: color-mix(in oklab, var(--color-amber-hot) 10%, transparent); }
|
||||||
|
.pill-alert { color: var(--color-red); border-color: color-mix(in oklab, var(--color-red) 45%, transparent); background: color-mix(in oklab, var(--color-red) 12%, transparent); }
|
||||||
|
.pill-off { color: var(--color-faint); border-color: var(--color-line); }
|
||||||
|
|
||||||
|
/* buttons */
|
||||||
|
.btn {
|
||||||
|
font-family: var(--font-display); font-size: 0.8rem; letter-spacing: 0.04em;
|
||||||
|
border: 1px solid var(--color-line2); color: var(--color-ink);
|
||||||
|
background: color-mix(in oklab, var(--color-hull2) 80%, transparent);
|
||||||
|
padding: 0.42rem 0.9rem; border-radius: 4px; cursor: pointer;
|
||||||
|
transition: border-color .12s, background .12s, color .12s;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--color-amber); color: var(--color-amber); }
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
/* privileged/destructive → violet clearance */
|
||||||
|
.btn-restricted { border-color: var(--color-vline); color: var(--color-violet-soft); }
|
||||||
|
.btn-restricted:hover { border-color: var(--color-violet); color: var(--color-violet); background: color-mix(in oklab, var(--color-violet) 12%, transparent); }
|
||||||
|
.btn-alert:hover { border-color: var(--color-red); color: var(--color-red); }
|
||||||
|
|
||||||
|
/* a subtle blinking cursor for the terminal feel */
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
.cursor::after { content: "▊"; margin-left: 0.15em; animation: blink 1.1s step-end infinite; color: var(--color-amber); }
|
||||||
|
|
||||||
|
/* thin scrollbars */
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: var(--color-line2) transparent; }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script>
|
||||||
|
// Bespoke SVG sparkline — themed, no charting lib. points: [{t, v}].
|
||||||
|
let { points = [], color = 'var(--color-teal)', height = 120, label = '' } = $props()
|
||||||
|
|
||||||
|
function build(pts) {
|
||||||
|
const nums = (pts || []).map((p) => Number(p.v)).filter((v) => !Number.isNaN(v))
|
||||||
|
if (nums.length < 2) return { d: '', area: '', min: null, max: null, last: nums[0] ?? null }
|
||||||
|
const min = Math.min(...nums)
|
||||||
|
const max = Math.max(...nums)
|
||||||
|
const span = max - min || 1
|
||||||
|
const W = 100
|
||||||
|
const H = 100
|
||||||
|
const step = W / (nums.length - 1)
|
||||||
|
const coords = nums.map((v, i) => [i * step, H - ((v - min) / span) * H])
|
||||||
|
const d = coords.map((c, i) => (i ? 'L' : 'M') + c[0].toFixed(2) + ' ' + c[1].toFixed(2)).join(' ')
|
||||||
|
return { d, area: d + ` L ${W} ${H} L 0 ${H} Z`, min, max, last: nums[nums.length - 1] }
|
||||||
|
}
|
||||||
|
let g = $derived(build(points))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex items-baseline justify-between">
|
||||||
|
<span class="tag">{label}</span>
|
||||||
|
<span class="font-mono text-xs" style="color:{color}">{g.last ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:{height}px" class="border border-line bg-void2">
|
||||||
|
{#if g.d}
|
||||||
|
<path d={g.area} fill={color} opacity="0.08" />
|
||||||
|
<path d={g.d} fill="none" stroke={color} stroke-width="1.2" vector-effect="non-scaling-stroke" />
|
||||||
|
{:else}
|
||||||
|
<text x="50" y="52" text-anchor="middle" fill="var(--color-faint)" font-size="6">no data</text>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
<div class="tag mt-1 flex justify-between">
|
||||||
|
<span>min {g.min?.toFixed?.(0) ?? '—'}</span><span>max {g.max?.toFixed?.(0) ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script>
|
||||||
|
// Confirm-gate for high-consequence (violet/restricted) actions.
|
||||||
|
let { open = false, title = 'Confirm', message = '', confirmLabel = 'Confirm', onconfirm, oncancel } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" role="presentation" onclick={oncancel}>
|
||||||
|
<div class="panel panel-restricted w-full max-w-md p-5" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="tag mb-1">restricted action</div>
|
||||||
|
<h3 class="mb-2 font-display text-violet">{title}</h3>
|
||||||
|
<p class="mb-5 text-sm text-muted">{message}</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button class="btn" onclick={oncancel}>cancel</button>
|
||||||
|
<button class="btn btn-restricted" onclick={onconfirm}>{confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script>
|
||||||
|
import { router, go } from '../lib/router.svelte.js'
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'monitor', label: 'MONITOR' },
|
||||||
|
{ id: 'authorise', label: 'AUTHORISE' },
|
||||||
|
{ id: 'lifecycle', label: 'LIFECYCLE' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="flex items-center gap-1 border-b border-line px-4 py-2">
|
||||||
|
<div class="mr-6 font-display tracking-widest text-amber">FLEET<span class="text-violet">·</span>COMMAND</div>
|
||||||
|
{#each tabs as t}
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 font-display text-xs tracking-wider {router.path === t.id ? 'text-amber' : 'text-muted hover:text-ink'}"
|
||||||
|
style={router.path === t.id ? 'border-bottom:2px solid var(--color-amber)' : 'border-bottom:2px solid transparent'}
|
||||||
|
onclick={() => go(t.id)}>{t.label}</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script>
|
||||||
|
let { title = '', tag = '', restricted = false, children } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="panel {restricted ? 'panel-restricted' : ''} p-4">
|
||||||
|
{#if title || tag}
|
||||||
|
<header class="mb-3 flex items-baseline justify-between gap-3">
|
||||||
|
<h2 class="font-display text-sm {restricted ? 'text-violet' : 'text-amber'}">{title}</h2>
|
||||||
|
{#if tag}<span class="tag">{tag}</span>{/if}
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
{@render children?.()}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
let { state = 'off', label = '' } = $props()
|
||||||
|
const clsMap = { ok: 'pill-ok', warn: 'pill-warn', alert: 'pill-alert', off: 'pill-off' }
|
||||||
|
const textMap = { ok: 'verified', warn: 'degraded', alert: 'alert', off: 'offline' }
|
||||||
|
let cls = $derived(clsMap[state] || 'pill-off')
|
||||||
|
let text = $derived(label || textMap[state] || state)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="pill {cls}">{text}</span>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script>
|
||||||
|
import { session, logout } from '../lib/session.svelte.js'
|
||||||
|
let now = $state(new Date())
|
||||||
|
$effect(() => {
|
||||||
|
const id = setInterval(() => (now = new Date()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
})
|
||||||
|
let clock = $derived(now.toISOString().slice(11, 19) + 'Z')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-b border-line bg-void2 px-4 py-1 text-xs">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="pill pill-ok">● secure link</span>
|
||||||
|
<span class="tag">passkey · {session.admin || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="font-mono text-faint">{clock}</span>
|
||||||
|
{#if session.admin && session.admin !== 'dev-insecure'}
|
||||||
|
<button class="btn" onclick={logout}>sign out</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// api.js — thin JSON fetch wrapper for the manager's admin API. Sends the session
|
||||||
|
// cookie (same-origin) and the CSRF header on mutations; surfaces {error} bodies.
|
||||||
|
import { session } from './session.svelte.js'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(status, msg) {
|
||||||
|
super(msg)
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req(method, path, body) {
|
||||||
|
const headers = {}
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
if (method !== 'GET' && session.csrf) headers['X-Fleet-CSRF'] = session.csrf
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
session.admin = null
|
||||||
|
session.csrf = ''
|
||||||
|
throw new ApiError(401, 'not signed in')
|
||||||
|
}
|
||||||
|
const text = await res.text()
|
||||||
|
const data = text ? JSON.parse(text) : null
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(res.status, (data && data.error) || res.statusText)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (p) => req('GET', p),
|
||||||
|
post: (p, b) => req('POST', p, b),
|
||||||
|
put: (p, b) => req('PUT', p, b),
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Small formatting helpers shared across the deck.
|
||||||
|
|
||||||
|
export function relTime(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const t = new Date(iso).getTime()
|
||||||
|
if (!t) return '—'
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - t) / 1000))
|
||||||
|
if (s < 60) return s + 's ago'
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + 'm ago'
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago'
|
||||||
|
return Math.floor(s / 86400) + 'd ago'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function num(v, digits = 0) {
|
||||||
|
if (v === undefined || v === null || Number.isNaN(v)) return '—'
|
||||||
|
return Number(v).toFixed(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pct(v) {
|
||||||
|
if (v === undefined || v === null) return '—'
|
||||||
|
return num(v, 0) + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
// health → one of ok | warn | alert (drives the state pills / colours).
|
||||||
|
export function healthState(h) {
|
||||||
|
if (!h) return 'off'
|
||||||
|
if (h.nvme_root === 'down' || h.auth === 'down') return 'alert'
|
||||||
|
if ((h.tmpfs_used_pct || 0) >= 90 || (h.temp_c || 0) >= 85 || h.selinux === 'permissive' || h.nvme_root === 'degraded') return 'warn'
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortSerial(s) {
|
||||||
|
return s ? s.slice(0, 8) : '—'
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Minimal hash router (Svelte 5 rune store). Routes: monitor | authorise |
|
||||||
|
// lifecycle | enrol. The enrol route carries the one-time ?token= from the CLI
|
||||||
|
// link. Hash routing keeps the SPA a single embedded index.html (no server-side
|
||||||
|
// route table needed).
|
||||||
|
function parse() {
|
||||||
|
const raw = location.hash.replace(/^#\/?/, '')
|
||||||
|
const [path, query] = raw.split('?')
|
||||||
|
return { path: path || 'monitor', query: new URLSearchParams(query || '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = parse()
|
||||||
|
export const router = $state({ path: initial.path, token: initial.query.get('token') || '' })
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const r = parse()
|
||||||
|
router.path = r.path
|
||||||
|
router.token = r.query.get('token') || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function go(path) {
|
||||||
|
location.hash = '#/' + path
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Auth/session state (Svelte 5 rune store). `admin` is the signed-in passkey
|
||||||
|
// identity; `csrf` is echoed on mutations. In the manager's INSECURE dev mode
|
||||||
|
// /api/whoami returns admin="dev-insecure" with no auth, so the SPA renders
|
||||||
|
// without a passkey — handy for testing against a seeded insecure backend.
|
||||||
|
export const session = $state({ admin: null, csrf: '', ready: false })
|
||||||
|
|
||||||
|
export async function refreshSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/whoami', { credentials: 'same-origin' })
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json()
|
||||||
|
session.admin = d.admin
|
||||||
|
session.csrf = d.csrf || ''
|
||||||
|
} else {
|
||||||
|
session.admin = null
|
||||||
|
session.csrf = ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
session.admin = null
|
||||||
|
session.csrf = ''
|
||||||
|
}
|
||||||
|
session.ready = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSession(d) {
|
||||||
|
session.admin = d.admin
|
||||||
|
session.csrf = d.csrf || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: session.csrf ? { 'X-Fleet-CSRF': session.csrf } : {},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
session.admin = null
|
||||||
|
session.csrf = ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// webauthn.js — the browser side of the passkey ceremonies. Converts the
|
||||||
|
// manager's base64url-encoded options into the BufferSources navigator.credentials
|
||||||
|
// needs, runs the ceremony, and serialises the result back into the JSON shape
|
||||||
|
// go-webauthn parses. Login is usernameless (discoverable); registration is
|
||||||
|
// authorised by the one-time enrol token from `register-admin`.
|
||||||
|
import { api } from './api.js'
|
||||||
|
import { setSession } from './session.svelte.js'
|
||||||
|
|
||||||
|
function b64uToBuf(s) {
|
||||||
|
s = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
while (s.length % 4) s += '='
|
||||||
|
const bin = atob(s)
|
||||||
|
const buf = new Uint8Array(bin.length)
|
||||||
|
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i)
|
||||||
|
return buf.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufToB64u(buf) {
|
||||||
|
const bytes = new Uint8Array(buf)
|
||||||
|
let s = ''
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b)
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepCreation(pk) {
|
||||||
|
pk.challenge = b64uToBuf(pk.challenge)
|
||||||
|
pk.user.id = b64uToBuf(pk.user.id)
|
||||||
|
if (Array.isArray(pk.excludeCredentials)) {
|
||||||
|
for (const c of pk.excludeCredentials) c.id = b64uToBuf(c.id)
|
||||||
|
}
|
||||||
|
return pk
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepRequest(pk) {
|
||||||
|
pk.challenge = b64uToBuf(pk.challenge)
|
||||||
|
if (Array.isArray(pk.allowCredentials)) {
|
||||||
|
for (const c of pk.allowCredentials) c.id = b64uToBuf(c.id)
|
||||||
|
}
|
||||||
|
return pk
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAttestation(cred) {
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64u(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
authenticatorAttachment: cred.authenticatorAttachment || undefined,
|
||||||
|
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
|
||||||
|
attestationObject: bufToB64u(cred.response.attestationObject),
|
||||||
|
transports: cred.response.getTransports ? cred.response.getTransports() : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAssertion(cred) {
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64u(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
authenticatorAttachment: cred.authenticatorAttachment || undefined,
|
||||||
|
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
|
||||||
|
authenticatorData: bufToB64u(cred.response.authenticatorData),
|
||||||
|
signature: bufToB64u(cred.response.signature),
|
||||||
|
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passkeySupported() {
|
||||||
|
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyLogin runs the discoverable (usernameless) login ceremony and, on
|
||||||
|
// success, sets the session. Throws on failure/cancellation.
|
||||||
|
export async function passkeyLogin() {
|
||||||
|
const { cid, options } = await api.post('/auth/login/begin')
|
||||||
|
const cred = await navigator.credentials.get({ publicKey: prepRequest(options.publicKey) })
|
||||||
|
const res = await api.post(`/auth/login/finish?cid=${encodeURIComponent(cid)}`, serializeAssertion(cred))
|
||||||
|
setSession(res)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyRegister runs the registration ceremony authorised by the enrol token.
|
||||||
|
export async function passkeyRegister(token) {
|
||||||
|
const { cid, options } = await api.post(`/auth/register/begin?token=${encodeURIComponent(token)}`)
|
||||||
|
const cred = await navigator.credentials.create({ publicKey: prepCreation(options.publicKey) })
|
||||||
|
return api.post(`/auth/register/finish?cid=${encodeURIComponent(cid)}`, serializeAttestation(cred))
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import './app.css'
|
||||||
|
// Self-hosted fonts (no CDN) — matches the kiosk-app posture.
|
||||||
|
import '@fontsource/chakra-petch/400.css'
|
||||||
|
import '@fontsource/chakra-petch/600.css'
|
||||||
|
import '@fontsource/ibm-plex-mono/400.css'
|
||||||
|
import '@fontsource/ibm-plex-mono/500.css'
|
||||||
|
import '@fontsource/ibm-plex-sans/400.css'
|
||||||
|
|
||||||
|
import { mount } from 'svelte'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
export default mount(App, { target: document.getElementById('app') })
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import Panel from '../components/Panel.svelte'
|
||||||
|
import { relTime } from '../lib/fmt.js'
|
||||||
|
|
||||||
|
let pending = $state([])
|
||||||
|
let error = $state('')
|
||||||
|
let busy = $state('')
|
||||||
|
let binding = $state({})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const d = await api.get('/api/enrol/pending')
|
||||||
|
pending = d.pending || []
|
||||||
|
for (const e of pending) if (!binding[e.id]) binding[e.id] = {} // init bind targets
|
||||||
|
error = ''
|
||||||
|
} catch (e) {
|
||||||
|
error = e?.message || 'failed to load enrolment queue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function approve(e) {
|
||||||
|
busy = e.id
|
||||||
|
try {
|
||||||
|
await api.post(`/api/enrol/${e.id}/approve`, binding[e.id] || {})
|
||||||
|
delete binding[e.id]
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error = err?.message
|
||||||
|
} finally {
|
||||||
|
busy = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deny(e) {
|
||||||
|
busy = e.id
|
||||||
|
try {
|
||||||
|
await api.post(`/api/enrol/${e.id}/deny`)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error = err?.message
|
||||||
|
} finally {
|
||||||
|
busy = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
load()
|
||||||
|
const id = setInterval(load, 8000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-3 flex items-baseline justify-between">
|
||||||
|
<h1 class="font-display text-violet">ENROLMENT — PENDING AUTHORISATION</h1>
|
||||||
|
<span class="tag">{pending.length} awaiting · cross-check each fingerprint against the device splash</span>
|
||||||
|
</div>
|
||||||
|
{#if error}<div class="pill pill-alert mb-3">{error}</div>{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{#each pending as e (e.id)}
|
||||||
|
<div class="panel panel-restricted p-4">
|
||||||
|
<div class="tag mb-1">verify fingerprint</div>
|
||||||
|
<div class="mb-3 font-display text-lg leading-snug break-words" style="color:var(--color-green)">{e.fingerprint}</div>
|
||||||
|
<dl class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 font-mono text-xs text-muted">
|
||||||
|
<dt class="text-faint">enrol-id</dt><dd class="truncate">{e.id}</dd>
|
||||||
|
<dt class="text-faint">CN</dt><dd>{e.cn || '—'}</dd>
|
||||||
|
{#if e.context?.mac}<dt class="text-faint">MAC</dt><dd>{e.context.mac}</dd>{/if}
|
||||||
|
{#if e.context?.ek_hash}<dt class="text-faint">TPM EK</dt><dd class="truncate">{e.context.ek_hash}</dd>{/if}
|
||||||
|
<dt class="text-faint">age</dt><dd>{relTime(e.created_at)}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
<input class="rounded border border-line bg-void2 px-2 py-1 text-xs text-ink" placeholder="asset tag" bind:value={binding[e.id].asset_tag} />
|
||||||
|
<input class="rounded border border-line bg-void2 px-2 py-1 text-xs text-ink" placeholder="switch port" bind:value={binding[e.id].switch_port} />
|
||||||
|
<input class="rounded border border-line bg-void2 px-2 py-1 text-xs text-ink" placeholder="location" bind:value={binding[e.id].location} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex justify-end gap-2">
|
||||||
|
<button class="btn btn-alert" disabled={busy === e.id} onclick={() => deny(e)}>deny</button>
|
||||||
|
<button class="btn btn-restricted" disabled={busy === e.id} onclick={() => approve(e)}>
|
||||||
|
{busy === e.id ? 'signing…' : '⚿ approve + bind'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if !pending.length && !error}
|
||||||
|
<Panel title="queue clear" tag="no pending enrolments"><p class="text-sm text-muted">No devices are awaiting authorisation.</p></Panel>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script>
|
||||||
|
import { router } from '../lib/router.svelte.js'
|
||||||
|
import { passkeyRegister, passkeySupported } from '../lib/webauthn.js'
|
||||||
|
let busy = $state(false)
|
||||||
|
let done = $state(false)
|
||||||
|
let who = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
async function enrol() {
|
||||||
|
busy = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const r = await passkeyRegister(router.token)
|
||||||
|
who = r.admin
|
||||||
|
done = true
|
||||||
|
} catch (e) {
|
||||||
|
error = e?.message || 'enrolment failed'
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative z-10 flex min-h-full items-center justify-center p-6">
|
||||||
|
<div class="panel panel-restricted w-full max-w-md p-8 text-center">
|
||||||
|
<div class="tag mb-2">passkey enrolment</div>
|
||||||
|
<h1 class="mb-4 font-display text-xl text-amber">Register a hardware passkey</h1>
|
||||||
|
{#if done}
|
||||||
|
<p class="mb-4"><span class="pill pill-ok">enrolled · {who}</span></p>
|
||||||
|
<a class="btn" href="#/monitor">continue → sign in</a>
|
||||||
|
{:else if !router.token}
|
||||||
|
<p class="pill pill-alert">missing enrolment token — use the link from <code>register-admin</code></p>
|
||||||
|
{:else if !passkeySupported()}
|
||||||
|
<p class="pill pill-alert">this browser has no WebAuthn support</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-6 text-sm text-muted">Insert your YubiKey and tap to bind it to this fleet console.</p>
|
||||||
|
<button class="btn btn-restricted w-full py-3" disabled={busy} onclick={enrol}>
|
||||||
|
{busy ? 'waiting for passkey…' : '⚿ register passkey'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if error}<p class="mt-4"><span class="pill pill-alert">{error}</span></p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import Panel from '../components/Panel.svelte'
|
||||||
|
import Confirm from '../components/Confirm.svelte'
|
||||||
|
import { shortSerial } from '../lib/fmt.js'
|
||||||
|
|
||||||
|
let hosts = $state([])
|
||||||
|
let error = $state('')
|
||||||
|
let okMsg = $state('')
|
||||||
|
let selected = $state(null)
|
||||||
|
let busy = $state(false)
|
||||||
|
let confirmOpen = $state(false)
|
||||||
|
let form = $state({ target_version: '', kiosk_url: '', reboot_at: '', drain: false })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const d = await api.get('/api/hosts')
|
||||||
|
hosts = d.hosts || []
|
||||||
|
error = ''
|
||||||
|
} catch (e) {
|
||||||
|
error = e?.message || 'failed to load fleet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pick(h) {
|
||||||
|
selected = h
|
||||||
|
okMsg = ''
|
||||||
|
form = {
|
||||||
|
target_version: h.desired?.target_version || '',
|
||||||
|
kiosk_url: h.desired?.kiosk_url || '',
|
||||||
|
reboot_at: '',
|
||||||
|
drain: !!h.desired?.drain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let consequence = $derived(
|
||||||
|
[form.drain ? 'DRAIN this host' : null, form.reboot_at ? 'schedule a REBOOT' : null].filter(Boolean).join(' and ')
|
||||||
|
)
|
||||||
|
async function apply() {
|
||||||
|
confirmOpen = false
|
||||||
|
busy = true
|
||||||
|
error = ''
|
||||||
|
okMsg = ''
|
||||||
|
const body = {
|
||||||
|
target_version: form.target_version || undefined,
|
||||||
|
kiosk_url: form.kiosk_url || undefined,
|
||||||
|
drain: form.drain,
|
||||||
|
}
|
||||||
|
if (form.reboot_at) body.reboot_at = new Date(form.reboot_at).toISOString()
|
||||||
|
try {
|
||||||
|
const res = await api.put(`/api/hosts/${selected.identity.cert_serial}/desired`, body)
|
||||||
|
okMsg = res.pushed_live ? 'applied — pushed to the live host' : 'applied — host reconciles on next connect'
|
||||||
|
await load()
|
||||||
|
selected = hosts.find((h) => h.identity.cert_serial === selected.identity.cert_serial) || selected
|
||||||
|
} catch (e) {
|
||||||
|
error = e?.message
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function submit() {
|
||||||
|
// Gate destructive changes behind a confirm; a plain version/URL change applies directly.
|
||||||
|
if (form.drain || form.reboot_at) confirmOpen = true
|
||||||
|
else apply()
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-3 font-display text-amber">LIFECYCLE</h1>
|
||||||
|
{#if error}<div class="pill pill-alert mb-3">{error}</div>{/if}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each hosts as h (h.identity.cert_serial)}
|
||||||
|
<button class="panel w-full p-2 text-left {selected?.identity.cert_serial === h.identity.cert_serial ? 'panel-restricted' : ''}" onclick={() => pick(h)}>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-display text-sm text-ink">{h.identity.starship_name || shortSerial(h.identity.cert_serial)}</span>
|
||||||
|
<span class="tag">{h.version?.image_version || '—'}{h.desired?.drain ? ' · drain' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
{#if selected}
|
||||||
|
<Panel title="desired state — {selected.identity.starship_name || shortSerial(selected.identity.cert_serial)}" tag="server-authoritative" restricted>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="tag">target version</span>
|
||||||
|
<input class="mt-1 w-full rounded border border-line bg-void2 px-2 py-1.5 text-sm text-ink" bind:value={form.target_version} placeholder="e.g. 0.5.0" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="tag">kiosk url</span>
|
||||||
|
<input class="mt-1 w-full rounded border border-line bg-void2 px-2 py-1.5 text-sm text-ink" bind:value={form.kiosk_url} placeholder="http://boot.fleet.lab/kiosk/" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="tag">reboot at (optional)</span>
|
||||||
|
<input type="datetime-local" class="mt-1 w-full rounded border border-line bg-void2 px-2 py-1.5 text-sm text-ink" bind:value={form.reboot_at} />
|
||||||
|
</label>
|
||||||
|
<label class="mt-4 flex items-center gap-2">
|
||||||
|
<input type="checkbox" bind:checked={form.drain} />
|
||||||
|
<span class="font-display text-sm text-violet-soft">drain (stop taking work)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
{#if okMsg}<span class="pill pill-ok">{okMsg}</span>{:else}<span></span>{/if}
|
||||||
|
<button class="btn btn-restricted" disabled={busy} onclick={submit}>{busy ? 'applying…' : '⚿ apply desired state'}</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
{:else}
|
||||||
|
<Panel title="lifecycle control" tag="select a host"><p class="text-sm text-muted">Select a host to set its server-authoritative desired state (version pin, kiosk URL, reboot, drain).</p></Panel>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Confirm
|
||||||
|
open={confirmOpen}
|
||||||
|
title="Confirm high-consequence change"
|
||||||
|
message={`This will ${consequence} on ${selected?.identity.starship_name || ''}. It is pushed to the live host immediately.`}
|
||||||
|
confirmLabel="apply"
|
||||||
|
onconfirm={apply}
|
||||||
|
oncancel={() => (confirmOpen = false)}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script>
|
||||||
|
import { passkeyLogin, passkeySupported } from '../lib/webauthn.js'
|
||||||
|
let busy = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
async function signIn() {
|
||||||
|
busy = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
await passkeyLogin()
|
||||||
|
} catch (e) {
|
||||||
|
error = e?.message || 'sign-in failed'
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative z-10 flex min-h-full items-center justify-center p-6">
|
||||||
|
<div class="panel panel-restricted w-full max-w-md p-8 text-center">
|
||||||
|
<div class="tag mb-2">clearance required</div>
|
||||||
|
<h1 class="mb-1 font-display text-2xl text-amber">FLEET<span class="text-violet">·</span>COMMAND</h1>
|
||||||
|
<p class="mb-6 text-sm text-muted">Authenticate with your hardware passkey to access the fleet control plane.</p>
|
||||||
|
{#if !passkeySupported()}
|
||||||
|
<p class="pill pill-alert">this browser has no WebAuthn support</p>
|
||||||
|
{:else}
|
||||||
|
<button class="btn btn-restricted w-full py-3" disabled={busy} onclick={signIn}>
|
||||||
|
{busy ? 'waiting for passkey…' : '⚿ sign in with passkey'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if error}<p class="mt-4"><span class="pill pill-alert">{error}</span></p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import Panel from '../components/Panel.svelte'
|
||||||
|
import StatePill from '../components/StatePill.svelte'
|
||||||
|
import Chart from '../components/Chart.svelte'
|
||||||
|
import { relTime, pct, num, healthState, shortSerial } from '../lib/fmt.js'
|
||||||
|
|
||||||
|
let hosts = $state([])
|
||||||
|
let error = $state('')
|
||||||
|
let selected = $state(null)
|
||||||
|
let metrics = $state({ tmpfs_used_pct: [], cpu_pct: [], temp_c: [] })
|
||||||
|
let metricsErr = $state('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const d = await api.get('/api/hosts')
|
||||||
|
hosts = d.hosts || []
|
||||||
|
error = ''
|
||||||
|
if (selected) selected = hosts.find((h) => h.identity.cert_serial === selected.identity.cert_serial) || selected
|
||||||
|
} catch (e) {
|
||||||
|
error = e?.message || 'failed to load fleet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function select(h) {
|
||||||
|
selected = h
|
||||||
|
metricsErr = ''
|
||||||
|
for (const f of Object.keys(metrics)) {
|
||||||
|
try {
|
||||||
|
const d = await api.get(`/api/hosts/${h.identity.cert_serial}/metrics?field=${f}&range=6h`)
|
||||||
|
metrics[f] = d.points || []
|
||||||
|
} catch (e) {
|
||||||
|
metrics[f] = []
|
||||||
|
metricsErr = e?.message || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drift(h) {
|
||||||
|
const run = h.version?.image_version
|
||||||
|
const want = h.desired?.target_version
|
||||||
|
return want && run && want !== run
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
load()
|
||||||
|
const id = setInterval(load, 10000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="mb-3 flex items-baseline justify-between">
|
||||||
|
<h1 class="font-display text-amber">FLEET ROSTER</h1>
|
||||||
|
<span class="tag">{hosts.length} hosts · {hosts.filter((h) => h.online).length} online</span>
|
||||||
|
</div>
|
||||||
|
{#if error}<div class="pill pill-alert mb-3">{error}</div>{/if}
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{#each hosts as h (h.identity.cert_serial)}
|
||||||
|
<button class="panel p-3 text-left {selected?.identity.cert_serial === h.identity.cert_serial ? 'panel-restricted' : ''}" onclick={() => select(h)}>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-display text-ink">{h.identity.starship_name || shortSerial(h.identity.cert_serial)}</span>
|
||||||
|
<StatePill state={h.online ? healthState(h.health) : 'off'} />
|
||||||
|
</div>
|
||||||
|
<div class="tag mt-0.5">{shortSerial(h.identity.cert_serial)} · {h.binding?.location || 'unbound'}</div>
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-2 font-mono text-xs text-muted">
|
||||||
|
<div><span class="text-faint">ver</span> {h.version?.image_version || '—'}{#if drift(h)}<span class="text-amber-hot"> ⇢ {h.desired.target_version}</span>{/if}</div>
|
||||||
|
<div><span class="text-faint">tmpfs</span> {pct(h.health?.tmpfs_used_pct)}</div>
|
||||||
|
<div><span class="text-faint">seen</span> {h.online ? 'live' : relTime(h.last_seen)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{#if h.desired?.drain}<span class="pill pill-warn">drain</span>{/if}
|
||||||
|
{#if drift(h)}<span class="pill pill-warn">version-drift</span>{/if}
|
||||||
|
{#if h.health?.selinux === 'permissive'}<span class="pill pill-alert">selinux permissive</span>{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if !hosts.length && !error}<p class="text-muted">no hosts enrolled yet — approve one in AUTHORISE.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if selected}
|
||||||
|
<Panel title={selected.identity.starship_name || shortSerial(selected.identity.cert_serial)} tag="host detail">
|
||||||
|
<dl class="grid grid-cols-2 gap-y-1 font-mono text-xs">
|
||||||
|
<dt class="text-faint">hostnqn</dt><dd class="truncate text-muted">{selected.identity.hostnqn || '—'}</dd>
|
||||||
|
<dt class="text-faint">running</dt><dd class="text-ink">{selected.version?.image_version || '—'}</dd>
|
||||||
|
<dt class="text-faint">desired</dt><dd class={drift(selected) ? 'text-amber-hot' : 'text-ink'}>{selected.desired?.target_version || '—'}</dd>
|
||||||
|
<dt class="text-faint">kernel</dt><dd class="truncate text-muted">{selected.version?.kernel || '—'}</dd>
|
||||||
|
<dt class="text-faint">auth</dt><dd class="text-muted">{selected.health?.auth || '—'}</dd>
|
||||||
|
<dt class="text-faint">nvme root</dt><dd class="text-muted">{selected.health?.nvme_root || '—'}</dd>
|
||||||
|
<dt class="text-faint">selinux</dt><dd class="text-muted">{selected.health?.selinux || '—'}</dd>
|
||||||
|
<dt class="text-faint">chrony</dt><dd class="text-muted">{selected.health?.chrony || '—'}</dd>
|
||||||
|
<dt class="text-faint">kiosk</dt><dd class="text-muted">{selected.health?.kiosk_active ? 'active' : 'idle'} · {selected.usage?.session_uptime || '—'}</dd>
|
||||||
|
<dt class="text-faint">last seen</dt><dd class="text-muted">{selected.online ? 'live' : relTime(selected.last_seen)}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<Chart label="tmpfs used %" color="var(--color-amber)" points={metrics.tmpfs_used_pct} />
|
||||||
|
<Chart label="cpu %" color="var(--color-teal)" points={metrics.cpu_pct} />
|
||||||
|
<Chart label="temp °C" color="var(--color-amber-hot)" points={metrics.temp_c} />
|
||||||
|
{#if metricsErr}<p class="tag text-faint">metrics: {metricsErr}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
{:else}
|
||||||
|
<Panel title="host detail" tag="select a ship">
|
||||||
|
<p class="text-sm text-muted">Select a host from the roster to see live health + telemetry charts.</p>
|
||||||
|
</Panel>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
export default { preprocess: vitePreprocess() }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// The SPA is embedded into the fleet-manager binary (go:embed ui/dist) and served
|
||||||
|
// same-origin over the admin plane in production. In dev, proxy the API + auth
|
||||||
|
// ceremonies to a locally-running manager.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte(), tailwindcss()],
|
||||||
|
build: { outDir: 'dist', emptyOutDir: true },
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://127.0.0.1:9500', changeOrigin: false },
|
||||||
|
'/auth': { target: 'http://127.0.0.1:9500', changeOrigin: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user