Alerts tab, Docker tab, nine new skins including HUD, a colour for every machine

Alerts: rules over sensors, watched fleet-wide from the hub; ntfy and webhook channels;
firing count on the rail. Docker: containers by stack with live use, start/stop/restart,
following logs, inspect, images/volumes/networks. Skins: HUD, Nord, Gruvbox, Catppuccin
(+Latte), Dracula, Tokyo Night, Solarized (+Light). Accents: per-machine colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 15:07:15 -07:00
co-authored by Claude Fable 5.1
parent 63fcba6077
commit cb1617c2b8
17 changed files with 1185 additions and 15 deletions
+130
View File
@@ -0,0 +1,130 @@
// Alerts: rules over sensors, watched by this hub for itself and every machine it manages.
import { useCallback, useEffect, useState } from 'react'
import { api, AlertChannel, AlertEvent, AlertRule, AlertsInfo } from './api'
import { accentFor } from './accents'
import { Btn, Loading, askYes, toast, useEscape } from './ui'
const OPS = ['>', '>=', '<', '<=', '==', '!=']
const ago = (ms: number) => { const s = Math.max(0, (Date.now() - ms) / 1000); return s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : s < 129600 ? `${Math.round(s / 3600)} h` : `${Math.round(s / 86400)} d` }
const patience = (s: number) => (s === 0 ? 'at once' : s < 120 ? `for ${s} s` : `for ${Math.round(s / 60)} min`)
const show = (v: unknown) => (typeof v === 'number' ? String(Math.round(v * 10) / 10) : String(v))
const parse = (raw: string): number | boolean | string => (raw === 'true' ? true : raw === 'false' ? false : raw.trim() !== '' && !isNaN(Number(raw)) ? Number(raw) : raw)
function RuleDialog({ rule, machines, onSave, onClose }: { rule: AlertRule; machines: string[]; onSave: (r: AlertRule) => void; onClose: () => void }) {
const [r, setR] = useState({ ...rule, value: String(rule.value) })
const [hits, setHits] = useState<string>('')
useEscape(onClose)
useEffect(() => { // what the selector matches right now, across the fleet
if (!r.sensor.trim()) { setHits(''); return }
const t = window.setTimeout(async () => {
try {
const res = await fetch('/api/fleet/sensors/query', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ machines: '*', query: { x: r.sensor.trim() } }) })
if (!res.ok) { setHits((await res.json().catch(() => ({}))).error ?? 'not a valid pattern'); return }
const by = await res.json() as Record<string, { x?: unknown; error?: string }>
const n = Object.entries(by).map(([m, v]) => [m, v.error ? 0 : v.x && typeof v.x === 'object' ? Object.keys(v.x).length : v.x === null || v.x === undefined ? 0 : 1] as const)
setHits(r.sensor.trim() === 'hamlab.reachable' ? 'every machine: whether it answers the hub' : n.every(([, c]) => c === 0) ? 'matches nothing right now' : 'matches ' + n.filter(([, c]) => c).map(([m, c]) => `${c} on ${m}`).join(', '))
} catch { setHits('') }
}, 350)
return () => clearTimeout(t)
}, [r.sensor])
const all = !r.machines || r.machines === '*'
const picked = all ? [] : r.machines.split(',').map((x) => x.trim()).filter(Boolean)
return (
<div className="modal-back" onClick={onClose}>
<form className="modal picker" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onSave({ ...r, name: r.name.trim(), sensor: r.sensor.trim(), value: parse(r.value) }) }}>
<h3>{rule.name ? 'Edit rule' : 'New rule'}</h3>
<label className="muted">Name<input autoFocus value={r.name} maxLength={64} onChange={(e) => setR({ ...r, name: e.target.value })} placeholder="Tank nearly full" /></label>
<label className="muted">Sensor — an ID or a pattern; the Widgets tab lists them all
<input className="mono" value={r.sensor} onChange={(e) => setR({ ...r, sensor: e.target.value })} placeholder="zfs.tank.used_pct" spellCheck={false} autoCapitalize="off" />
</label>
{hits && <div className="muted small">{hits}</div>}
<div className="rule-cond">
<select value={r.op} onChange={(e) => setR({ ...r, op: e.target.value })}>{OPS.map((o) => <option key={o}>{o}</option>)}</select>
<input className="mono" value={r.value} onChange={(e) => setR({ ...r, value: e.target.value })} placeholder="90 · true · unhealthy" spellCheck={false} autoCapitalize="off" />
<span className="muted">for</span>
<input className="mono" type="number" min={0} max={86400} value={r.for_sec} onChange={(e) => setR({ ...r, for_sec: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 90 }} /><span className="muted">s</span>
</div>
<div className="chips">{(['info', 'warn', 'crit'] as const).map((s) => <button type="button" key={s} className={'chip' + (r.severity === s ? ' on' : '')} onClick={() => setR({ ...r, severity: s })}>{s === 'crit' ? 'critical' : s === 'warn' ? 'warning' : 'info'}</button>)}</div>
<div className="chips">
<button type="button" className={'chip' + (all ? ' on' : '')} onClick={() => setR({ ...r, machines: '*' })}>every machine</button>
{machines.map((m) => <button type="button" key={m} className={'chip' + (picked.includes(m) ? ' on' : '')} onClick={() => { const next = picked.includes(m) ? picked.filter((x) => x !== m) : [...picked, m]; setR({ ...r, machines: next.length ? next.join(',') : '*' }) }}>{m}</button>)}
</div>
<div className="svc-actions"><button type="submit" className="btn on" disabled={!r.name.trim() || !r.sensor.trim()}>Save</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
</form>
</div>
)
}
export default function Alerts({ machines }: { machines: string[] }) {
const [a, setA] = useState<AlertsInfo | null>(null)
const [err, setErr] = useState('')
const [editing, setEditing] = useState<AlertRule | null>(null)
const [chan, setChan] = useState<AlertChannel>({ type: 'ntfy', url: '' })
const load = useCallback(async () => { try { const r = await api.alerts(); setA({ ...r, rules: r.rules ?? [], channels: r.channels ?? [], events: r.events ?? [], firing: r.firing ?? [] }); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 5000); return () => clearInterval(t) }, [load])
if (!a) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Loading alerts…" />}</div>
const save = async (patch: { rules?: AlertRule[]; channels?: AlertChannel[] }, done: string) => {
try { await api.saveAlerts(patch); toast(done); window.dispatchEvent(new Event('hamlab:alerts')); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const saveRule = (r: AlertRule) => { const exists = a.rules.some((x) => x.id === r.id); save({ rules: exists ? a.rules.map((x) => (x.id === r.id ? r : x)) : [...a.rules, r] }, exists ? 'rule saved' : 'rule added'); setEditing(null) }
const sevClass = (s: string) => (s === 'crit' ? 'bad' : s === 'warn' ? 'warn' : '')
return (
<div className="dash alerts">
<div className="dash-bar"><h2 className="section">Now</h2><span className="spacer" /><span className="muted small">checked {a.checked > 0 ? `${ago(a.checked)} ago` : 'not yet'} · every {a.every_sec} s · all machines</span></div>
{a.firing.length === 0 ? <section className="card all-clear"><span className="good">✓ All clear</span><span className="muted small"> — {a.rules.filter((r) => r.enabled).length} rules watching {machines.length + 1} machine{machines.length ? 's' : ''}</span></section> : (
<div className="cards">
{a.firing.map((f) => (
<section className={'card firing ' + sevClass(f.severity)} key={f.rule_id + f.machine + f.sensor}>
<div className="sub-head"><span className={'sdot ' + (f.severity === 'crit' ? 'bad' : 'warn')} /><span className="dname">{f.rule}</span><span className="spacer" /><span className="badge" style={{ color: accentFor(f.machine), borderColor: accentFor(f.machine) }}>{f.machine}</span></div>
<div className="mono small">{f.sensor} = {show(f.value)}</div>
<div className="muted small">for {ago(f.since)}</div>
</section>
))}
</div>
)}
<div className="dash-bar"><h2 className="section">Rules</h2><span className="spacer" />
<button className="btn" onClick={() => setEditing({ id: 'r' + Date.now().toString(36), name: '', sensor: '', op: '>=', value: 90, for_sec: 300, severity: 'warn', enabled: true, machines: '*' })}>+ Rule</button>
</div>
<section className="card rules">
{a.rules.length === 0 && <div className="empty">No rules. Nothing is being watched.</div>}
{a.rules.map((r) => (
<div className={'rule' + (r.enabled ? '' : ' off')} key={r.id}>
<button className={'toggle' + (r.enabled ? ' on' : '')} title={r.enabled ? 'watching — tap to pause' : 'paused — tap to watch'} onClick={() => save({ rules: a.rules.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)) }, r.enabled ? `${r.name}: paused` : `${r.name}: watching`)}><i /></button>
<div className="rule-body" onClick={() => setEditing(r)}>
<div className="rule-name"><span className={'sdot ' + (r.severity === 'crit' ? 'bad' : r.severity === 'warn' ? 'warn' : 'ok')} />{r.name}{a.firing.some((f) => f.rule_id === r.id) && <span className="badge bad">firing</span>}</div>
<div className="mono small muted">{r.sensor} {r.op} {show(r.value)} · {patience(r.for_sec)}{r.machines && r.machines !== '*' ? ` · ${r.machines}` : ''}</div>
</div>
<button className="icon-btn" title="delete" onClick={async () => { if (await askYes(`Delete the rule "${r.name}"?`)) save({ rules: a.rules.filter((x) => x.id !== r.id) }, 'rule deleted') }}>✕</button>
</div>
))}
</section>
<div className="dash-bar"><h2 className="section">Where alerts go</h2><span className="spacer" />{a.channels.length > 0 && <Btn className="btn" onClick={async () => { try { await api.testAlerts(); toast('test sent') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Send a test</Btn>}</div>
<section className="card">
<div className="muted small" style={{ marginBottom: 8 }}>Always here, and on the rail. To reach your phone: an <b>ntfy</b> topic URL (the ntfy app, from ntfy.sh or your own server), or a <b>webhook</b> — Home Assistant, Slack, anything that takes a POST. Sent when an alert fires and again when it clears.</div>
{a.channels.map((c, i) => <div className="row" key={i}><span className="badge" style={{ marginLeft: 0 }}>{c.type}</span><span className="k mono small">{c.url}</span><Btn className="btn danger" confirm={`Remove this ${c.type} channel?`} onClick={() => save({ channels: a.channels.filter((_, j) => j !== i) }, 'channel removed')}>Remove</Btn></div>)}
<form className="chan-add" onSubmit={(e) => { e.preventDefault(); if (!/^https?:\/\/./.test(chan.url.trim())) { toast('a channel needs an http(s) URL', 'err'); return } save({ channels: [...a.channels, { type: chan.type, url: chan.url.trim() }] }, 'channel added'); setChan({ ...chan, url: '' }) }}>
<select value={chan.type} onChange={(e) => setChan({ ...chan, type: e.target.value as AlertChannel['type'] })}><option value="ntfy">ntfy</option><option value="webhook">webhook</option></select>
<input className="mono" value={chan.url} onChange={(e) => setChan({ ...chan, url: e.target.value })} placeholder={chan.type === 'ntfy' ? 'https://ntfy.sh/your-secret-topic' : 'https://…/api/webhook/…'} spellCheck={false} autoCapitalize="off" />
<button className="btn on" disabled={!chan.url.trim()}>Add</button>
</form>
</section>
<div className="dash-bar"><h2 className="section">History</h2><span className="spacer" /><span className="muted small">the last {a.events.length}</span></div>
<section className="card">
{a.events.length === 0 && <div className="empty">Nothing has fired yet.</div>}
{a.events.map((e: AlertEvent, i) => (
<div className="ev" key={i}>
<span className={e.kind === 'firing' ? (e.severity === 'crit' ? 'warn-text bad-text' : 'warn-text') : 'good'}>{e.kind === 'firing' ? '▲' : '✓'}</span>
<span className="ev-main">{e.rule} <span className="muted">· {e.machine} · </span><span className="mono small muted">{e.sensor} = {show(e.value)}</span></span>
<span className="muted small num" title={new Date(e.t).toLocaleString()}>{ago(e.t)} ago</span>
</div>
))}
</section>
{editing && <RuleDialog rule={editing} machines={machines} onSave={saveRule} onClose={() => setEditing(null)} />}
</div>
)
}
+2
View File
@@ -5,6 +5,7 @@ import Shell from './Shell'
import { Dialogs } from './ui'
import { applyTheme, currentThemeId } from './themes'
import { getRefreshMs, setRefreshMs } from './prefs'
import { setAccents } from './accents'
export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined)
@@ -15,6 +16,7 @@ export default function App() {
api.uiPrefs().then((p) => {
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false)
}).catch(() => { /* keep local */ })
} catch (e) { if (e instanceof Unauthenticated) setMe(null) }
}, [])
+143
View File
@@ -0,0 +1,143 @@
// Docker: containers grouped by the stack that deployed them, with start / stop / restart,
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
// come from the same container sensors everything else uses.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, DockerContainer, DockerInfo } from './api'
import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
import { Btn, Loading, toast } from './ui'
type Tab = 'containers' | 'images' | 'volumes' | 'networks'
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
function LogView({ id }: { id: string }) {
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
const [err, setErr] = useState('')
const [follow, setFollow] = useState(true)
const box = useRef<HTMLDivElement>(null)
const last = useRef('')
useEffect(() => {
let stop = false; setLines([]); last.current = ''
const tick = async () => {
if (stop) return
if (document.visibilityState === 'visible') {
try {
const r = await api.dockerLogs(id, last.current)
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) }
setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
window.setTimeout(tick, 2000)
}
tick()
return () => { stop = true }
}, [id])
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
return (
<div className="logs">
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div>
{err && <div className="error">{err}</div>}
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
</div>
</div>
)
}
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
const [tab, setTab] = useState<'logs' | 'inspect'>('logs')
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name])
const act = (a: 'start' | 'stop' | 'restart') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
const running = c.state === 'running'
const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
return (
<div className="svc-detail">
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
<div className="props">
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
<div><span className="k">Status</span><span>{c.status}</span></div>
{c.stack && <div><span className="k">Stack</span><span>{c.stack}{c.service && c.service !== c.name ? ` · ${c.service}` : ''}</span></div>}
{running && <div><span className="k">Using</span><span>{fmtValue(s('cpu')?.value, '%')} of a core · {fmtValue(s('mem')?.value ?? c.mem, 'B')}</span></div>}
{ports(c.ports) && <div><span className="k">Ports</span><span className="mono small">{ports(c.ports)}</span></div>}
</div>
<div className="svc-actions">
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
</div>
<div className="tabs"><button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button><button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button></div>
{tab === 'logs' && <LogView id={c.name} />}
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
<div className="props">
<div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div>
<div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div>
{Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>}
{!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>}
{!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>}
{mounts.map((m) => <div key={m.Destination}><span className="k">Mount</span><span className="mono small">{m.Source} → {m.Destination}{m.RW ? '' : ' (ro)'}</span></div>)}
<div><span className="k">Env</span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ')} <span className="muted">(names only)</span></span></div>
</div>
))}
</div>
)
}
export default function Docker() {
const [d, setD] = useState<DockerInfo | null>(null)
const [err, setErr] = useState('')
const [tab, setTab] = useState<Tab>('containers')
const [filter, setFilter] = useState('')
const [sel, setSel] = useHashSel()
const rate = useRefreshMs()
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
const sensors = live.data?.c ?? {}
const byStack = useMemo(() => {
const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>()
for (const c of d?.containers ?? []) { if (f && !`${c.name} ${c.image} ${c.stack}`.toLowerCase().includes(f)) continue; const k = c.stack || 'started by hand'; out.set(k, [...(out.get(k) ?? []), c]) }
return out
}, [d, filter])
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
const cur = d.containers.find((c) => c.name === sel)
const up = d.containers.filter((c) => c.state === 'running').length
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
const cols: Record<Exclude<Tab, 'containers'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] }
return (
<div className={'svc docker' + (cur && tab === 'containers' ? ' has-sel' : '')}>
<div className="svc-list">
<div className="svc-tools">
<div className="chips">{(['containers', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (tab === t ? ' on' : '')} onClick={() => setTab(t)}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : d[t].length}</button>)}</div>
{tab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
</div>
<div className="svc-rows">
{tab === 'containers' && [...byStack].map(([stack, list]) => (
<div key={stack}>
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
{list.map((c) => (
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
<span className={'sdot ' + dot(c)} />
<div className="svc-name"><div className="n">{c.name}</div><div className="d">{c.image}</div></div>
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
</div>
))}
</div>
))}
{tab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
{tab !== 'containers' && (
<table className="dtable"><thead><tr>{cols[tab].map(([, h]) => <th key={h}>{h}</th>)}</tr></thead>
<tbody>{d[tab].map((row, i) => <tr key={i}>{cols[tab].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}</tr>)}</tbody></table>
)}
</div>
</div>
{tab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a container.</div>)}
</div>
)
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { MachineStatus } from './api'
import { useEscape } from './ui'
import { accentFor } from './accents'
const hostOf = (url: string) => { try { return new URL(url).hostname } catch { return url } }
@@ -30,11 +31,11 @@ export default function MachineSwitcher({ hub, hubAddr, current, machines, onCha
{open && (
<div className="mmenu">
<button className={'mitem' + (!current ? ' on' : '')} onClick={() => { setOpen(false); onChange(null) }}>
<span className="dot ok" /><span className="mname">{hub}</span><span className="muted small mono">{hubAddr || 'this host'}</span>
<span className="dot ok" /><span className="mname" style={{ color: accentFor(hub) }}>{hub}</span><span className="muted small mono">{hubAddr || 'this host'}</span>
</button>
{machines.map((m) => (
<button key={m.name} className={'mitem' + (current === m.name ? ' on' : '')} onClick={() => { setOpen(false); onChange(m.name) }} title={m.error || m.url}>
<span className={'dot ' + dotFor(m)} /><span className="mname">{m.name}</span><span className="muted small mono">{hostOf(m.url)}{m.online ? '' : ' · offline'}</span>
<span className={'dot ' + dotFor(m)} /><span className="mname" style={{ color: accentFor(m.name) }}>{m.name}</span><span className="muted small mono">{hostOf(m.url)}{m.online ? '' : ' · offline'}</span>
</button>
))}
<button className="mitem manage" onClick={() => { setOpen(false); onManage() }}>+ manage machines…</button>
+18 -2
View File
@@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, MachineStatus, Me } from './api'
import { Btn, Loading, askYes, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import { ACCENTS, accentFor, getAccents, setAccents } from './accents'
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
const ago = (iso?: string) => { if (!iso) return 'never'; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
@@ -67,8 +68,9 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
<div className="dash machines">
<div className="dash-bar"><h2 className="section">This host</h2></div>
<section className="card">
<div className="sub-head"><span className="dot ok" /><span className="dname">{hub.host}</span><span className="badge">hub</span><span className="spacer" /><span className="num muted">v{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}</span></div>
<div className="sub-head"><span className="dot ok" /><span className="dname" style={{ color: accentFor(hub.host) }}>{hub.host}</span><span className="badge">hub</span><span className="spacer" /><span className="num muted">v{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}</span></div>
<div className="muted small">{fp ? <>Certificate <span className="mono">{fp}</span></> : 'Serving plain HTTP (behind a reverse proxy). Set "tls": "auto" in config.json for a self-signed certificate if another hub should manage this host.'}</div>
<Swatches name={hub.host} />
</section>
<div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2><span className="spacer" />{list.some(outdated) && <Btn className="btn on" onClick={updateAll}>Update {list.filter(outdated).length} outdated</Btn>}</div>
@@ -79,7 +81,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
<section className="card" key={m.name}>
<div className="sub-head">
<span className={'dot ' + (m.online ? 'ok' : 'bad')} />
<span className="dname">{m.name}</span>
<span className="dname" style={{ color: accentFor(m.name) }}>{m.name}</span>
<span className="muted small mono">{m.url}</span>
<span className="spacer" />
{m.reboot_needed && <span className="badge" title="reboot required">⟳</span>}
@@ -93,6 +95,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
{m.fingerprint && <div><span className="k">Cert</span><span className="mono" title={m.fingerprint}>{shortFp(m.fingerprint)}</span></div>}
<div><span className="k">Added</span><span>{new Date(m.added).toLocaleDateString()}</span></div>
</div>
<Swatches name={m.name} />
<div className="svc-actions">
<button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button>
{outdated(m) && <Btn className="btn" onClick={() => update(m)}>Update</Btn>}
@@ -116,3 +119,16 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
</div>
)
}
// The colour a machine wears in the header stripe and the switcher: ten to tap, or any other.
export function Swatches({ name }: { name: string }) {
const [, bump] = useState(0)
const cur = accentFor(name)
const pick = (c: string) => { setAccents({ ...getAccents(), [name]: c }); bump((n) => n + 1) }
return (
<div className="swatches" title={`${name}'s colour — it marks the header whenever you are on this machine`}>
{ACCENTS.map((c) => <button key={c} className={'swatch' + (c.toLowerCase() === cur.toLowerCase() ? ' on' : '')} style={{ background: c }} aria-label={c} onClick={() => pick(c)} />)}
<input type="color" value={cur} onChange={(e) => pick(e.target.value)} aria-label="another colour" />
</div>
)
}
+3 -2
View File
@@ -4,9 +4,9 @@ import { ReactNode } from 'react'
// for the roadmap.
export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean }
interface Props { groups: Group[]; view: string; header?: ReactNode; onView: (id: string) => void }
interface Props { groups: Group[]; view: string; header?: ReactNode; badges?: Record<string, number>; onView: (id: string) => void }
export default function Rail({ groups, view, header, onView }: Props) {
export default function Rail({ groups, view, header, badges, onView }: Props) {
return (
<nav className="rail">
{header}
@@ -17,6 +17,7 @@ export default function Rail({ groups, view, header, onView }: Props) {
<span className="gicon">{g.icon}</span>
<span>{g.label}</span>
{g.soon && <span className="badge">soon</span>}
{!!badges?.[g.id] && <span className="rail-badge" title={`${badges[g.id]} firing`}>{badges[g.id]}</span>}
</button>
</div>
))}
+3 -2
View File
@@ -1,6 +1,7 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { fetchCatalog } from './sensors'
import { api, Me } from './api'
import { savePrefs } from './accents'
import { applyTheme, currentThemeId, THEMES } from './themes'
import { Btn, Loading, askYes, toast, useEscape } from './ui'
import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } from './prefs'
@@ -45,11 +46,11 @@ export default function Settings({ me }: { me: Me }) {
const pick = async (id: string) => {
applyTheme(id); setTheme(id)
try { await api.saveUIPrefs({ theme: id, refreshMs: getRefreshMs() }) } catch { /* local still applied */ }
await savePrefs()
}
const pickRefresh = async (ms: number) => {
setRefreshMs(ms); setRefresh(ms)
try { await api.saveUIPrefs({ theme: currentThemeId(), refreshMs: ms }) } catch { /* local still applied */ }
await savePrefs()
}
async function changePassword(e: FormEvent) {
+31 -5
View File
@@ -6,6 +6,8 @@ import TermTabs from './TermTabs'
import KeyBar from './KeyBar'
import Dashboard from './Dashboard'
import Gallery from './Gallery'
import Docker from './Docker'
import Alerts from './Alerts'
import Services from './Services'
import Cron from './Cron'
import Updates from './Updates'
@@ -18,6 +20,7 @@ import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power'
import { applyAccent } from './accents'
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
@@ -34,23 +37,36 @@ const ClockIcon = (
</svg>
)
const DockerIcon = (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
<path d="M2 5.2 8 2l6 3.2v5.6L8 14l-6-3.2z" /><path d="M2 5.2 8 8.4l6-3.2M8 8.4V14" />
</svg>
)
const BellIcon = (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 11V7.2a4 4 0 0 1 8 0V11l1.2 1.6H2.8z" /><path d="M6.6 14.2h2.8" />
</svg>
)
const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Monitors', icon: '◔' },
{ id: 'procs', label: 'Processes', icon: '≡' },
{ id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'cron', label: 'Cron', icon: ClockIcon },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
{ id: 'machines', label: 'Machines', icon: '⌂' },
{ id: 'widgets', label: 'Widgets', icon: '▦' },
{ id: 'settings', label: 'Settings', icon: '⚙' },
]
type View = 'terminal' | 'dashboard' | 'widgets' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
const HUB_ONLY: View[] = ['machines', 'settings']
type View = 'terminal' | 'dashboard' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
const HUB_ONLY: View[] = ['machines', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
// forward work, reload restores the tab, and links can point at a page on a machine.
@@ -106,6 +122,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
}
// keep the hash in step, and follow it when the browser navigates
const [firing, setFiring] = useState(0)
useEffect(() => {
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
}, [])
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
// The tab's title says where you are, so history entries and open tabs can be told apart.
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host])
useEffect(() => {
@@ -216,7 +240,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
</header>
<div className="body">
<Rail groups={GROUPS} view={view}
<Rail groups={GROUPS} view={view} badges={{ alerts: firing }}
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
<div className="backdrop" onClick={() => setDrawer(false)} />
@@ -226,6 +250,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />}
{view === 'docker' && <Docker />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} />}
{view === 'procs' && <Procs />}
{view === 'services' && <Services />}
{view === 'cron' && <Cron />}
@@ -257,7 +283,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{view === 'dashboard' ? 'Monitors' : view === 'widgets' ? 'Widgets' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
<span className="muted">{view === 'dashboard' ? 'Monitors' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
+25
View File
@@ -0,0 +1,25 @@
// Each machine wears a colour — a stripe across the header, its name in the switcher — so
// you always know which box you are typing into. A colour you pick wins; otherwise the
// machine's name picks one, the same one on every device.
import { api } from './api'
import { currentThemeId } from './themes'
import { getRefreshMs } from './prefs'
export const ACCENTS = ['#e3a33b', '#3fb950', '#58a6ff', '#f778ba', '#ff7b72', '#a371f7', '#39c5cf', '#d4d44a', '#ff9e64', '#9aa7b4']
const KEY = 'hamlab.accents'
export function getAccents(): Record<string, string> { try { return JSON.parse(localStorage.getItem(KEY) ?? '{}') ?? {} } catch { return {} } }
export function accentFor(name: string): string {
const picked = getAccents()[name]
if (picked && /^#[0-9a-f]{6}$/i.test(picked)) return picked
let h = 0; for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
return ACCENTS[h % ACCENTS.length]
}
export function applyAccent(name: string) { document.documentElement.style.setProperty('--machine', accentFor(name)) }
export function setAccents(all: Record<string, string>, save = true) {
localStorage.setItem(KEY, JSON.stringify(all))
window.dispatchEvent(new Event('hamlab:accents'))
if (save) savePrefs()
}
// Everything the hub remembers about how the console looks, saved as one object.
export function savePrefs() { return api.saveUIPrefs({ theme: currentThemeId(), refreshMs: getRefreshMs(), accents: getAccents() }).catch(() => { /* local still applied */ }) }
+15 -2
View File
@@ -101,6 +101,12 @@ export interface StoragePart { name: string; type: string; fstype?: string; moun
export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] }
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
export interface AlertChannel { type: 'ntfy' | 'webhook'; url: string }
export interface AlertEvent { t: number; kind: 'firing' | 'resolved'; rule: string; rule_id: string; machine: string; sensor: string; value: unknown; severity: string }
export interface AlertsInfo { rules: AlertRule[]; channels: AlertChannel[]; events: AlertEvent[]; firing: (AlertEvent & { since: number })[]; checked: number; every_sec: number }
export interface ZFSSnapshot { name: string; used: number; created: number }
export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] }
@@ -182,9 +188,16 @@ export const api = {
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
uiPrefs: () => req<{ theme?: string; refreshMs?: number; accents?: Record<string, string> }>('GET', '/api/prefs/ui', undefined, true),
saveUIPrefs: (p: { theme?: string; refreshMs?: number; accents?: Record<string, string> }) => req<void>('PUT', '/api/prefs/ui', p, true),
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
terminals: () => req<Term[]>('GET', '/api/terminals'),
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
+45
View File
@@ -438,3 +438,48 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
.ttab.drop { box-shadow: inset 3px 0 0 var(--accent2); }
.ttab-move { display: none; } .ttab-move .icon-btn:disabled { opacity: .3; }
@media (pointer: coarse) { .ttab-move { display: inline-flex; } } /* no drag and drop on a touch screen */
/* each machine's colour */
.header { box-shadow: inset 0 3px 0 var(--machine, var(--accent)); }
.mswitch-btn .mname, .header .host { color: var(--machine, var(--accent)); }
.swatches { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
.swatch { width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; }
.swatch.on { border-color: var(--fg); box-shadow: 0 0 0 2px var(--bg2) inset; }
.swatches input[type=color] { width: 30px; height: 30px; padding: 0; border: 1px solid var(--border); border-radius: 50%; background: none; cursor: pointer; }
.themes { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
/* HUD: light drawn on glass — hairlines, no fills, thin type */
[data-theme="hud"] .tile, [data-theme="hud"] .card, [data-theme="hud"] .box, [data-theme="hud"] .modal { background: rgba(53, 214, 255, .03); border-color: rgba(53, 214, 255, .28); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .05), inset 0 0 24px rgba(53, 214, 255, .04); }
[data-theme="hud"] .tile::before, [data-theme="hud"] .card::before { content: ''; position: absolute; top: -1px; left: -1px; width: 12px; height: 12px; border-top: 2px solid var(--accent); border-left: 2px solid var(--accent); }
[data-theme="hud"] .tile, [data-theme="hud"] .card { position: relative; }
[data-theme="hud"] body { font-weight: 300; letter-spacing: .2px; }
[data-theme="hud"] .tile .value, [data-theme="hud"] .brand { font-weight: 300; letter-spacing: 1.5px; text-shadow: 0 0 10px rgba(53, 214, 255, .55); }
[data-theme="hud"] .tile .label, [data-theme="hud"] .card h2, [data-theme="hud"] h2.section { letter-spacing: 2px; color: var(--accent); opacity: .8; }
[data-theme="hud"] .meter { height: 3px; border-radius: 0; } [data-theme="hud"] .meter .fill { border-radius: 0; box-shadow: 0 0 8px var(--s1); }
[data-theme="hud"] .rail, [data-theme="hud"] .header, [data-theme="hud"] .footer { background: rgba(3, 7, 12, .92); border-color: rgba(53, 214, 255, .22); }
[data-theme="hud"] .group-head.active { background: linear-gradient(90deg, rgba(53, 214, 255, .16), transparent); box-shadow: inset 2px 0 0 var(--accent); }
[data-theme="hud"] .btn, [data-theme="hud"] .chip { background: transparent; border-color: rgba(53, 214, 255, .35); border-radius: 2px; } [data-theme="hud"] .btn.on, [data-theme="hud"] .chip.on { background: rgba(53, 214, 255, .16); color: var(--accent2); border-color: var(--accent); }
[data-theme="hud"] .app { background: radial-gradient(ellipse at 50% -20%, rgba(53, 214, 255, .10), transparent 60%), var(--bg); }
[data-theme="hud"] .gauge-svg path:first-child { stroke-width: 3; } [data-theme="hud"] .gauge-svg path:nth-child(2) { stroke-width: 5; filter: drop-shadow(0 0 4px var(--s1)); }
/* alerts */
.card.all-clear { padding: 16px; } .card.firing { border-color: var(--s2); } .card.firing.bad { border-color: var(--bad); box-shadow: 0 0 0 1px var(--bad) inset; }
.rule { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--border); } .rule:first-child { border-top: 0; padding-top: 0; } .rule.off .rule-body { opacity: .5; }
.rule-body { flex: 1; min-width: 0; cursor: pointer; } .rule-name { display: flex; align-items: center; gap: 8px; } .rule-body .mono { overflow-wrap: anywhere; margin-top: 2px; }
.toggle { width: 38px; height: 22px; border-radius: 11px; background: var(--bg3); border: 1px solid var(--border); position: relative; flex-shrink: 0; padding: 0; } .toggle i { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); transition: left .15s, background .15s; }
.toggle.on { background: color-mix(in srgb, var(--ok) 25%, var(--bg3)); border-color: var(--ok); } .toggle.on i { left: 18px; background: var(--ok); }
.rule-cond { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .rule-cond select, .rule-cond input, .chan-add select, .chan-add input { padding: 8px 9px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; } .rule-cond input.mono { flex: 1 1 120px; min-width: 0; }
.chan-add { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } .chan-add input { flex: 1 1 220px; min-width: 0; }
.ev { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 13px; } .ev:first-child { border-top: 0; } .ev-main { flex: 1; min-width: 0; overflow-wrap: anywhere; } .bad-text { color: var(--bad) !important; }
.rail-badge { margin-left: auto; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--bad); color: #fff; font-size: 11px; font-weight: 700; text-align: center; line-height: 18px; }
@media (pointer: coarse) { .toggle { width: 46px; height: 28px; border-radius: 14px; } .toggle i { width: 22px; height: 22px; } .toggle.on i { left: 20px; } }
/* docker */
.stack-head { margin: 0; padding: 8px 12px 4px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; background: var(--bg2); position: sticky; top: 0; z-index: 1; }
.docker .svc-state { text-align: right; line-height: 1.35; }
.dtable { width: 100%; border-collapse: collapse; } .dtable th { text-align: left; padding: 8px 12px; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; position: sticky; top: 0; background: var(--bg2); }
.dtable td { padding: 7px 12px; border-top: 1px solid var(--border); overflow-wrap: anywhere; }
.docker:not(.has-sel) .svc-list { flex: 1; width: auto; max-width: 760px; }
.docker .svc-tools .chips { flex-wrap: wrap; } .docker .svc-name { flex: 1; min-width: 0; } .docker .svc-name .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docker .svc-row { min-width: 0; } .docker .svc-state { flex-shrink: 0; }
.alerts .row { display: flex; align-items: center; gap: 8px; } .alerts .row .badge { flex: 0 0 auto; } .alerts .row .k { flex: 1; min-width: 0; overflow-wrap: anywhere; }
+41
View File
@@ -16,6 +16,20 @@ const base = {
'--mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
}
// A palette in one line: surfaces, text, the two accents, status colours, and the sixteen
// terminal colours (black red green yellow blue magenta cyan white, then the bright eight).
function mk(id: string, name: string, blurb: string, ui: string[], term: string[], extra: Record<string, string> = {}): Theme {
const [bg, bg2, bg3, border, fg, muted, accent, accent2, ok, bad, s1, s2, termBg] = ui
const k = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite']
const light = parseInt(bg.slice(1, 3), 16) > 128
return {
id, name, blurb,
vars: { ...base, '--bg': bg, '--bg2': bg2, '--bg3': bg3, '--border': border, '--fg': fg, '--muted': muted, '--accent': accent, '--accent2': accent2, '--ok': ok, '--bad': bad, '--s1': s1, '--s2': s2,
'--track': light ? 'rgba(0, 0, 0, .10)' : 'rgba(255, 255, 255, .09)', '--term-bg': termBg, '--btn-fg': light ? '#fff' : '#111', ...extra },
xterm: { background: termBg, foreground: fg, cursor: accent, selectionBackground: light ? 'rgba(0,0,0,.18)' : 'rgba(255,255,255,.22)', ...Object.fromEntries(k.map((name, i) => [name, term[i]])) },
}
}
export const THEMES: Theme[] = [
{
id: 'dark', name: 'Dark', blurb: 'The default. Graphite surfaces, amber brand, blue accents.',
@@ -57,6 +71,33 @@ export const THEMES: Theme[] = [
brightBlack: '#6b4e14', brightRed: '#ff8c5a', brightGreen: '#ffe08a', brightYellow: '#ffe066', brightBlue: '#f0b040', brightMagenta: '#ffb870', brightCyan: '#ffdd99', brightWhite: '#fff3cc',
},
},
mk('hud', 'HUD', 'Hairlines of cyan light on near-black. Thin type, no fills. At your service.',
['#03070c', '#050c14', '#0a1a28', '#123047', '#bfe9ff', '#5b8ba6', '#35d6ff', '#7df9ff', '#4dffb5', '#ff5470', '#35d6ff', '#ffb547', '#02050a'],
['#02050a', '#ff5470', '#4dffb5', '#ffb547', '#35a8ff', '#b28dff', '#35d6ff', '#bfe9ff', '#3a5a70', '#ff8398', '#8dffd0', '#ffd08a', '#7cc6ff', '#cdb5ff', '#7df9ff', '#ffffff']),
mk('nord', 'Nord', 'Arctic blue-greys. Calm, low contrast, easy at night.',
['#2e3440', '#3b4252', '#434c5e', '#4c566a', '#d8dee9', '#8f9bb3', '#88c0d0', '#81a1c1', '#a3be8c', '#bf616a', '#81a1c1', '#ebcb8b', '#2b303b'],
['#3b4252', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#88c0d0', '#e5e9f0', '#4c566a', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#8fbcbb', '#eceff4']),
mk('gruvbox', 'Gruvbox', 'Warm retro earth tones. Cream on charcoal.',
['#282828', '#32302f', '#3c3836', '#504945', '#ebdbb2', '#a89984', '#fabd2f', '#83a598', '#b8bb26', '#fb4934', '#83a598', '#fe8019', '#1d2021'],
['#282828', '#cc241d', '#98971a', '#d79921', '#458588', '#b16286', '#689d6a', '#a89984', '#928374', '#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#ebdbb2']),
mk('catppuccin', 'Catppuccin', 'Mocha: soft pastels on deep mauve-grey.',
['#1e1e2e', '#181825', '#313244', '#45475a', '#cdd6f4', '#9399b2', '#cba6f7', '#89b4fa', '#a6e3a1', '#f38ba8', '#89b4fa', '#fab387', '#11111b'],
['#45475a', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#f5c2e7', '#94e2d5', '#bac2de', '#585b70', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#f5c2e7', '#94e2d5', '#a6adc8']),
mk('latte', 'Catppuccin Latte', 'The same pastels, by daylight.',
['#eff1f5', '#e6e9ef', '#dce0e8', '#bcc0cc', '#4c4f69', '#7c7f93', '#8839ef', '#1e66f5', '#40a02b', '#d20f39', '#1e66f5', '#fe640b', '#e6e9ef'],
['#5c5f77', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#acb0be', '#6c6f85', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#bcc0cc']),
mk('dracula', 'Dracula', 'Purple, pink and green on midnight.',
['#282a36', '#21222c', '#343746', '#44475a', '#f8f8f2', '#8b93b8', '#bd93f9', '#8be9fd', '#50fa7b', '#ff5555', '#8be9fd', '#ffb86c', '#1e1f29'],
['#21222c', '#ff5555', '#50fa7b', '#f1fa8c', '#bd93f9', '#ff79c6', '#8be9fd', '#f8f8f2', '#6272a4', '#ff6e6e', '#69ff94', '#ffffa5', '#d6acff', '#ff92df', '#a4ffff', '#ffffff']),
mk('tokyo', 'Tokyo Night', 'Neon signs over a dark blue city.',
['#1a1b26', '#16161e', '#24283b', '#343b58', '#c0caf5', '#737aa2', '#7aa2f7', '#bb9af7', '#9ece6a', '#f7768e', '#7aa2f7', '#ff9e64', '#13141c'],
['#15161e', '#f7768e', '#9ece6a', '#e0af68', '#7aa2f7', '#bb9af7', '#7dcfff', '#a9b1d6', '#414868', '#f7768e', '#9ece6a', '#e0af68', '#7aa2f7', '#bb9af7', '#7dcfff', '#c0caf5']),
mk('solarized', 'Solarized Dark', 'The precise, low-glare classic.',
['#002b36', '#073642', '#0a4352', '#1a5666', '#93a1a1', '#657b83', '#b58900', '#268bd2', '#859900', '#dc322f', '#268bd2', '#cb4b16', '#00212b'],
['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#586e75', '#cb4b16', '#93a1a1', '#b58900', '#839496', '#6c71c4', '#2aa198', '#fdf6e3']),
mk('solarized-light', 'Solarized Light', 'The same sixteen colours on warm paper.',
['#fdf6e3', '#eee8d5', '#e4ddc8', '#d3cbb4', '#586e75', '#839496', '#b58900', '#268bd2', '#859900', '#dc322f', '#268bd2', '#cb4b16', '#eee8d5'],
['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3']),
]
export const DEFAULT_THEME = 'dark'