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:
@@ -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>
|
||||
Reference in New Issue
Block a user