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,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