Files
diskless-workstation/serving/fleet-manager/ui/src/pages/Monitor.svelte
T
Audiobuzz e6ae8c4b23 fleet-manager UI: auto-refresh the selected host's metric graphs
The Monitor tab polled /api/hosts every 10s (roster + host-detail refresh) but
only fetched a host's metric series on click, so the tmpfs/cpu/temp graphs went
stale until you re-selected. Extract loadMetrics() and call it from the same 10s
tick when a host is selected, so the graphs stay live.
2026-07-10 06:22:33 +00:00

124 lines
5.8 KiB
Svelte

<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 loadMetrics(serial) {
metricsErr = ''
for (const f of Object.keys(metrics)) {
try {
const d = await api.get(`/api/hosts/${serial}/metrics?field=${f}&range=6h`)
metrics[f] = d.points || []
} catch (e) {
metrics[f] = []
metricsErr = e?.message || ''
}
}
}
async function select(h) {
selected = h
await loadMetrics(h.identity.cert_serial)
}
// One tick: refresh the roster (+ host-detail via the re-point in load()) and,
// if a host is selected, its metric series too — so the graphs stay live
// without re-clicking.
async function refresh() {
await load()
if (selected) await loadMetrics(selected.identity.cert_serial)
}
function drift(h) {
const run = h.version?.image_version
const want = h.desired?.target_version
return want && run && want !== run
}
$effect(() => {
refresh()
const id = setInterval(refresh, 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-ink">
<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>