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
+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>
)
}