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.
124 lines
5.8 KiB
Svelte
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>
|