Show container image-update status from Dockhand

Dockhand already tracks per-container image updates internally (its
UI shows this) via a cached check plus an on-demand recheck -- surface
that here instead of only showing running/stopped state.

adapter.listContainers() now also reads GET /api/containers/pending-
updates per environment (a cached read, no registry hit) and merges
each container's hasImageUpdate/newerVersion/checkedAt onto it.
updateAvailable is a tri-state: true (update pending), false (checked,
up to date), or null (never checked) -- distinguishing "no update"
from "we don't know yet" matters since a container can sit unchecked
indefinitely until someone triggers a check.

New adapter.checkForUpdates() triggers a fresh check across every
environment (POST /api/containers/check-updates, one registry lookup
per container so this can take a while) and new POST /:id/dockhand/
check-updates route (operator+, matching the existing container
action's role gating).

Docker page: new "Update" column (badge + tooltip with the newer
version), an "updates available" count next to the running/total
count, and a "Check for updates" button. Dashboard's Dockhand widget
also gained an "Updates" mini-stat, swapped in for the less useful
"Not running" figure (already inferable from running/total).

Field names (hasImageUpdate, newerVersion, checkedAt, the check-
updates response shape) came from Dockhand's own published OpenAPI
spec, not guessed -- and verified against a local mock Dockhand server
covering all three update states (pending, up to date, never checked)
plus the check-updates aggregation across environments, since this
sandbox can't reach the user's real Dockhand instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-18 21:04:15 +02:00
1 parent 7564796c39
commit f2a6253ddf
6 files changed
+142 -19

No files matched your search

+4 -2
View File
@@ -77,8 +77,10 @@ All modules from the original plan are built:
(with a failing-build warning). (with a failing-build warning).
- **Docker** — container status across every Docker host Dockhand - **Docker** — container status across every Docker host Dockhand
manages (one credential covers all of them), with manages (one credential covers all of them), with
start/stop/restart actions, a host filter, and a live start/stop/restart actions, a host filter, image-update status per
running/total widget. container (from Dockhand's own cached update check, plus a button
to trigger a fresh one), and a live running/total widget (with an
updates-available count).
The Integrations page itself is now just a list of configured The Integrations page itself is now just a list of configured
integrations (name/type/status, visible to every role) with an integrations (name/type/status, visible to every role) with an
+49 -11
View File
@@ -30,6 +30,10 @@ export interface DockhandContainer {
status: string; // human string, e.g. "Up 2 hours (healthy)" status: string; // human string, e.g. "Up 2 hours (healthy)"
environmentId: number; environmentId: number;
environmentName: string; environmentName: string;
/** null = this container has never been checked for updates. */
updateAvailable: boolean | null;
newerVersion: string | null;
checkedAt: string | null;
} }
export interface DockhandAdapter { export interface DockhandAdapter {
@@ -38,6 +42,8 @@ export interface DockhandAdapter {
startContainer(environmentId: number, containerId: string): Promise<void>; startContainer(environmentId: number, containerId: string): Promise<void>;
stopContainer(environmentId: number, containerId: string): Promise<void>; stopContainer(environmentId: number, containerId: string): Promise<void>;
restartContainer(environmentId: number, containerId: string): Promise<void>; restartContainer(environmentId: number, containerId: string): Promise<void>;
/** Triggers a fresh image-update check across every environment. Can take a while — one registry lookup per container. */
checkForUpdates(): Promise<{ total: number; updatesFound: number }>;
} }
export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter { export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
@@ -82,16 +88,29 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
const perEnv = await Promise.all( const perEnv = await Promise.all(
environments.map(async (env) => { environments.map(async (env) => {
try { try {
const data = await api("GET", `/api/containers?env=${env.id}&all=true`); const [data, pending] = await Promise.all([
return (Array.isArray(data) ? data : []).map((c: any) => ({ api("GET", `/api/containers?env=${env.id}&all=true`),
id: c.id, // Cached read (no fresh registry hit) — a not-yet-checked environment
name: c.name, // shouldn't fail the whole container list, so it just leaves every
image: c.image, // container's update status as "never checked" (null).
state: c.state, api("GET", `/api/containers/pending-updates?env=${env.id}`).catch(() => null),
status: c.status, ]);
environmentId: env.id, const pendingById = new Map<string, any>((pending?.pendingUpdates ?? []).map((p: any) => [p.containerId, p]));
environmentName: env.name, return (Array.isArray(data) ? data : []).map((c: any) => {
})); const record = pendingById.get(c.id);
return {
id: c.id,
name: c.name,
image: c.image,
state: c.state,
status: c.status,
environmentId: env.id,
environmentName: env.name,
updateAvailable: record ? !!record.hasImageUpdate : null,
newerVersion: record?.newerVersion ?? null,
checkedAt: record?.checkedAt ?? null,
};
});
} catch { } catch {
// one unreachable host shouldn't take down the whole dashboard view // one unreachable host shouldn't take down the whole dashboard view
return []; return [];
@@ -101,6 +120,25 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
return perEnv.flat(); return perEnv.flat();
} }
async function checkForUpdates(): Promise<{ total: number; updatesFound: number }> {
const environments = await listEnvironments();
const results = await Promise.all(
environments.map(async (env) => {
try {
const data = await api("POST", `/api/containers/check-updates?env=${env.id}`);
return { total: data?.total ?? 0, updatesFound: data?.updatesFound ?? 0 };
} catch {
// one unreachable host shouldn't abort checking the others
return { total: 0, updatesFound: 0 };
}
}),
);
return results.reduce((acc, r) => ({ total: acc.total + r.total, updatesFound: acc.updatesFound + r.updatesFound }), {
total: 0,
updatesFound: 0,
});
}
async function startContainer(environmentId: number, containerId: string): Promise<void> { async function startContainer(environmentId: number, containerId: string): Promise<void> {
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`); await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`);
} }
@@ -123,5 +161,5 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
} }
} }
return withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer }); return withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer, checkForUpdates });
} }
+24
View File
@@ -489,6 +489,30 @@ integrationsRouter.get("/:id/dockhand/containers", asyncHandler(async (req, res)
} }
})); }));
integrationsRouter.post(
"/:id/dockhand/check-updates",
requireRole("operator"),
asyncHandler(async (req, res) => {
const found = await requireDockhandAdapter(req, res);
if (!found) return;
try {
const result = await found.adapter.checkForUpdates();
await recordAudit({
actor: req.currentUser!,
category: "integration",
action: "check_updates",
targetType: "dockhand_integration",
targetId: found.integration.id,
detail: result,
});
res.json(result);
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}),
);
const dockhandActions = ["start", "stop", "restart"] as const; const dockhandActions = ["start", "stop", "restart"] as const;
for (const action of dockhandActions) { for (const action of dockhandActions) {
+7
View File
@@ -374,6 +374,9 @@ export interface DockhandContainer {
status: string; status: string;
environmentId: number; environmentId: number;
environmentName: string; environmentName: string;
updateAvailable: boolean | null;
newerVersion: string | null;
checkedAt: string | null;
} }
export interface DockhandContainersResponse { export interface DockhandContainersResponse {
@@ -740,6 +743,10 @@ export const api = {
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`, `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`,
{ method: "POST" }, { method: "POST" },
), ),
checkForUpdates: (integrationId: number) =>
request<{ total: number; updatesFound: number }>(`/api/integrations/${integrationId}/dockhand/check-updates`, {
method: "POST",
}),
}, },
semaphore: { semaphore: {
templates: (integrationId: number) => templates: (integrationId: number) =>
+5 -3
View File
@@ -201,6 +201,7 @@ interface DockhandSummary {
running: number; running: number;
total: number; total: number;
hostCount: number; hostCount: number;
updatesAvailable: number;
stateBreakdown: { label: string; count: number }[]; stateBreakdown: { label: string; count: number }[];
} }
@@ -323,6 +324,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
running: res.summary.running, running: res.summary.running,
total: res.summary.total, total: res.summary.total,
hostCount: new Set(res.containers.map((c) => c.environmentName)).size, hostCount: new Set(res.containers.map((c) => c.environmentName)).size,
updatesAvailable: res.containers.filter((c) => c.updateAvailable).length,
stateBreakdown: breakdownFrom(res.containers, (c) => c.state), stateBreakdown: breakdownFrom(res.containers, (c) => c.state),
}), }),
) )
@@ -607,9 +609,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</div> </div>
<div className="col-4"> <div className="col-4">
<MiniStat <MiniStat
label="Not running" label="Updates"
value={dockhandSummary!.total - dockhandSummary!.running} value={dockhandSummary!.updatesAvailable}
accent={dockhandSummary!.total - dockhandSummary!.running > 0 ? "#f59e0b" : undefined} accent={dockhandSummary!.updatesAvailable > 0 ? "#f59e0b" : undefined}
/> />
</div> </div>
</div> </div>
+53 -3
View File
@@ -11,6 +11,16 @@ import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
function updateBadge(c: DockhandContainer) {
if (c.updateAvailable === null) return <span className="text-secondary">—</span>;
if (!c.updateAvailable) return <span className="badge bg-green-lt text-green">Up to date</span>;
return (
<span className="badge bg-yellow-lt text-yellow" title={c.newerVersion ?? undefined}>
Update available
</span>
);
}
function containerStateBadge(state: string) { function containerStateBadge(state: string) {
switch (state) { switch (state) {
case "running": case "running":
@@ -38,6 +48,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [actingOnContainer, setActingOnContainer] = useState<string | null>(null); const [actingOnContainer, setActingOnContainer] = useState<string | null>(null);
const [hostFilter, setHostFilter] = useState(""); const [hostFilter, setHostFilter] = useState("");
const [checkingUpdates, setCheckingUpdates] = useState(false);
useEffect(() => { useEffect(() => {
api.integrations api.integrations
@@ -82,11 +93,32 @@ export default function Docker({ user }: { user: CurrentUser }) {
if (!sorted) return; if (!sorted) return;
downloadCsv( downloadCsv(
"docker-containers.csv", "docker-containers.csv",
["Container", "Host", "Image", "State", "Status"], ["Container", "Host", "Image", "State", "Status", "Update available"],
sorted.map((c) => [c.name, c.environmentName, c.image, c.state, c.status]), sorted.map((c) => [
c.name,
c.environmentName,
c.image,
c.state,
c.status,
c.updateAvailable === null ? "unknown" : c.updateAvailable ? "yes" : "no",
]),
); );
} }
async function checkForUpdates() {
if (!selectedId) return;
setCheckingUpdates(true);
setError(null);
try {
await api.integrations.dockhand.checkForUpdates(selectedId);
loadContainers(selectedId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCheckingUpdates(false);
}
}
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") { async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
if (!selectedId) return; if (!selectedId) return;
if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return; if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return;
@@ -150,6 +182,11 @@ export default function Docker({ user }: { user: CurrentUser }) {
{data.summary.running}/{data.summary.total} running {data.summary.running}/{data.summary.total} running
</span> </span>
)} )}
{data && data.containers.filter((c) => c.updateAvailable).length > 0 && (
<span className="text-yellow fw-normal ms-2">
{data.containers.filter((c) => c.updateAvailable).length} update(s) available
</span>
)}
</h3> </h3>
<div className="card-actions d-flex gap-2"> <div className="card-actions d-flex gap-2">
{hosts.length > 1 && ( {hosts.length > 1 && (
@@ -170,6 +207,11 @@ export default function Docker({ user }: { user: CurrentUser }) {
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}> <button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
Export CSV Export CSV
</button> </button>
{canEdit && (
<button className="btn btn-sm btn-outline-secondary" onClick={checkForUpdates} disabled={checkingUpdates}>
{checkingUpdates ? "Checking…" : "Check for updates"}
</button>
)}
<button <button
className="btn btn-sm btn-outline-secondary" className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadContainers(selected.id)} onClick={() => selected && loadContainers(selected.id)}
@@ -193,6 +235,13 @@ export default function Docker({ user }: { user: CurrentUser }) {
/> />
<SortableTh<DockhandContainer> label="Image" sortKeyName="image" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<DockhandContainer> label="Image" sortKeyName="image" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DockhandContainer> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<DockhandContainer> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DockhandContainer>
label="Update"
sortKeyName="updateAvailable"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
{canEdit && <th className="w-1">Actions</th>} {canEdit && <th className="w-1">Actions</th>}
</tr> </tr>
</thead> </thead>
@@ -206,6 +255,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
{containerStateBadge(c.state)} {containerStateBadge(c.state)}
<div className="text-secondary small">{c.status}</div> <div className="text-secondary small">{c.status}</div>
</td> </td>
<td>{updateBadge(c)}</td>
{canEdit && ( {canEdit && (
<td> <td>
<div className="btn-list flex-nowrap"> <div className="btn-list flex-nowrap">
@@ -242,7 +292,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
))} ))}
{data && sorted?.length === 0 && ( {data && sorted?.length === 0 && (
<tr> <tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center"> <td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
{hostFilter ? `No containers found on "${hostFilter}".` : "No containers found across any environment."} {hostFilter ? `No containers found on "${hostFilter}".` : "No containers found across any environment."}
</td> </td>
</tr> </tr>