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:
Audiobuzz
2026-07-04 05:54:41 +00:00
parent c40dd5ac93
commit fae3624c58
30 changed files with 3203 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.local
+45
View File
@@ -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.
+13
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+33
View File
@@ -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}
+139
View File
@@ -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>
+39
View File
@@ -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),
}
+34
View File
@@ -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))
}
+12
View File
@@ -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() }
+17
View File
@@ -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 },
},
},
})