docker: drop the fleet-wide updates list

A view shows one machine: the one in the switcher. Seeing another machine's
state means switching to it, not a hub-side page that reaches across — and
this was one. Recorded as a decision so it does not get rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-25 00:19:12 -07:00
co-authored by Claude Opus 5
parent 608f0b7005
commit b8c654b61b
4 changed files with 4 additions and 54 deletions
+3 -2
View File
@@ -18,6 +18,7 @@ stub tab or a roadmap item.
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. | | Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. | | Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. | | Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
## Layout ## Layout
@@ -580,8 +581,8 @@ something long in one, switch away and back, refresh — it's all still there.
keeps its own 4000-entry ring, since `docker events` forgets after about a minute), keeps its own 4000-entry ring, since `docker events` forgets after about a minute),
**Shell** (`docker exec`, handed to the Terminal tab) and **Limits** (`docker update` for **Shell** (`docker exec`, handed to the Terminal tab) and **Limits** (`docker update` for
memory, CPU and restart policy); an **updates** tab giving the whole machine at once with memory, CPU and restart policy); an **updates** tab giving the whole machine at once with
a per-container indicator in the list; the same across every machine, asked of the hub; a per-container indicator in the list; and **schedules**, which write
and **schedules**, which write `/etc/cron.d/hamlab-docker` and call `/etc/cron.d/hamlab-docker` and call
`hamlab docker-update <id>` — the shape the ZFS schedules already use. `hamlab docker-update <id>` — the shape the ZFS schedules already use.
Also done: Ham Lab keeps working when `dockhand` is removed. It remembers where a Also done: Ham Lab keeps working when `dockhand` is removed. It remembers where a
container's bind mounts pointed (`mounts` in `state.json`) and resolves a stack through container's bind mounts pointed (`mounts` in `state.json`) and resolves a stack through
+1 -49
View File
@@ -2,7 +2,7 @@
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory // logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
// come from the same container sensors everything else uses. // come from the same container sensors everything else uses.
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, apiPath, DockerContainer, DockerEvent, DockerHealth, DockerInfo, ImageCheck, MachineStatus, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow } from './api' import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow } from './api'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import StackNew, { DupFrom } from './StackNew' import StackNew, { DupFrom } from './StackNew'
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors' import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
@@ -456,7 +456,6 @@ function UpdateDot({ state }: { state?: string }) {
// What is out of date here, and the schedules that keep it from happening again. // What is out of date here, and the schedules that keep it from happening again.
function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u: MachineUpdates) => void }) { function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u: MachineUpdates) => void }) {
const onHub = apiPath('/api/x') === '/api/x'
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [jobs, setJobs] = useState<UpdateJobRow[] | null>(null) const [jobs, setJobs] = useState<UpdateJobRow[] | null>(null)
const [file, setFile] = useState('') const [file, setFile] = useState('')
@@ -502,8 +501,6 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
</section> </section>
)} )}
{onHub && <Fleet />}
<div className="dash-bar" style={{ marginTop: 14 }}><h2 className="section">Schedules</h2><span className="spacer" /> <div className="dash-bar" style={{ marginTop: 14 }}><h2 className="section">Schedules</h2><span className="spacer" />
<Btn className="btn on" onClick={() => setEditing({ cron: '0 3 * * *', apply: true, enabled: true })}>+ New schedule</Btn> <Btn className="btn on" onClick={() => setEditing({ cron: '0 3 * * *', apply: true, enabled: true })}>+ New schedule</Btn>
</div> </div>
@@ -541,51 +538,6 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
) )
} }
// Every other machine at once. Only shown on the hub, because only the hub can reach them,
// and each answer is that machine's own six-hour cache — nothing is checked here without
// being asked for.
function Fleet() {
const [rows, setRows] = useState<{ name: string; online: boolean; upd?: MachineUpdates; err?: string }[] | null>(null)
const [busy, setBusy] = useState('')
const load = useCallback(async (refresh?: string) => {
let nodes: MachineStatus[] = []
try { nodes = (await api.machines()).nodes } catch { setRows([]); return }
const out = await Promise.all(nodes.map(async (n) => {
if (!n.online) return { name: n.name, online: false }
try { return { name: n.name, online: true, upd: await api.nodeUpdates(n.name, refresh === n.name) } }
catch (e) { return { name: n.name, online: true, err: e instanceof Error ? e.message : 'failed' } }
}))
setRows(out)
}, [])
useEffect(() => { load() }, [load])
if (rows === null) return <div style={{ marginTop: 14 }}><Loading label="Asking the other machines…" /></div>
if (rows.length === 0) return null
return (
<>
<h2 className="section" style={{ marginTop: 14 }}>The other machines</h2>
<section className="card">
{rows.map((r) => {
const behind = (r.upd?.images ?? []).filter((i) => i.state === 'update')
return (
<div className="row" key={r.name}>
<span className="k">{r.name}</span>
<span>
{!r.online ? <span className="muted">offline</span>
: r.err ? <span className="muted small">{r.err}</span>
: !r.upd?.checked ? <span className="muted">not checked</span>
: behind.length === 0 ? <span className="muted">all current</span>
: <span className="warn-text" title={behind.map((i) => i.image).join('\n')}>{behind.length} behind</span>}
</span>
<span className="row-end">{r.online && !r.err && <Btn className="link" busy={busy === r.name} onClick={async () => { setBusy(r.name); try { await load(r.name) } finally { setBusy('') } }}>check</Btn>}</span>
</div>
)
})}
</section>
<div className="muted small">Open a machine from the switcher to schedule anything on it — a schedule lives on the machine that runs it.</div>
</>
)
}
function JobDialog({ j, stacks, onClose, onSaved }: { j: Partial<UpdateJob>; stacks: string[]; onClose: () => void; onSaved: () => void }) { function JobDialog({ j, stacks, onClose, onSaved }: { j: Partial<UpdateJob>; stacks: string[]; onClose: () => void; onSaved: () => void }) {
const [f, setF] = useState<Partial<UpdateJob>>(j) const [f, setF] = useState<Partial<UpdateJob>>(j)
const [svc, setSvc] = useState((j.services ?? []).join(' ')) const [svc, setSvc] = useState((j.services ?? []).join(' '))
-2
View File
@@ -257,8 +257,6 @@ export const api = {
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`), 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`), dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`), machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
// One named machine, asked of the hub — the fleet view, which is on the hub by definition.
nodeUpdates: (node: string, refresh = false) => req<MachineUpdates>('GET', `/api/nodes/${encodeURIComponent(node)}/docker/updates${refresh ? '?refresh=1' : ''}`, undefined, true),
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'), updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
saveUpdateJob: (j: Partial<UpdateJob>) => req<UpdateJob>('PUT', '/api/docker/schedules', j), saveUpdateJob: (j: Partial<UpdateJob>) => req<UpdateJob>('PUT', '/api/docker/schedules', j),
deleteUpdateJob: (id: string) => req<void>('DELETE', `/api/docker/schedules/${encodeURIComponent(id)}`), deleteUpdateJob: (id: string) => req<void>('DELETE', `/api/docker/schedules/${encodeURIComponent(id)}`),
-1
View File
@@ -767,4 +767,3 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; } .kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; } .updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); } .updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
.card > .row > .row-end { text-align: right; white-space: nowrap; }