From f2a6253ddf6f22ae07daab2889807aa1358d34f9 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Fri, 18 Sep 2026 21:04:15 +0200 Subject: [PATCH] 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 --- README.md | 6 ++- server/src/integrations/dockhand/adapter.ts | 60 +++++++++++++++++---- server/src/routes/integrations.ts | 24 +++++++++ web/src/api/client.ts | 7 +++ web/src/pages/Dashboard.tsx | 8 +-- web/src/pages/Docker.tsx | 56 +++++++++++++++++-- 6 files changed, 142 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4fe5452..160c1d7 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,10 @@ All modules from the original plan are built: (with a failing-build warning). - **Docker** — container status across every Docker host Dockhand manages (one credential covers all of them), with - start/stop/restart actions, a host filter, and a live - running/total widget. + start/stop/restart actions, a host filter, image-update status per + 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 integrations (name/type/status, visible to every role) with an diff --git a/server/src/integrations/dockhand/adapter.ts b/server/src/integrations/dockhand/adapter.ts index a2816bc..a6c12a3 100644 --- a/server/src/integrations/dockhand/adapter.ts +++ b/server/src/integrations/dockhand/adapter.ts @@ -30,6 +30,10 @@ export interface DockhandContainer { status: string; // human string, e.g. "Up 2 hours (healthy)" environmentId: number; environmentName: string; + /** null = this container has never been checked for updates. */ + updateAvailable: boolean | null; + newerVersion: string | null; + checkedAt: string | null; } export interface DockhandAdapter { @@ -38,6 +42,8 @@ export interface DockhandAdapter { startContainer(environmentId: number, containerId: string): Promise; stopContainer(environmentId: number, containerId: string): Promise; restartContainer(environmentId: number, containerId: string): Promise; + /** 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 { @@ -82,16 +88,29 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter { const perEnv = await Promise.all( environments.map(async (env) => { try { - const data = await api("GET", `/api/containers?env=${env.id}&all=true`); - return (Array.isArray(data) ? data : []).map((c: any) => ({ - id: c.id, - name: c.name, - image: c.image, - state: c.state, - status: c.status, - environmentId: env.id, - environmentName: env.name, - })); + const [data, pending] = await Promise.all([ + api("GET", `/api/containers?env=${env.id}&all=true`), + // Cached read (no fresh registry hit) — a not-yet-checked environment + // shouldn't fail the whole container list, so it just leaves every + // container's update status as "never checked" (null). + api("GET", `/api/containers/pending-updates?env=${env.id}`).catch(() => null), + ]); + const pendingById = new Map((pending?.pendingUpdates ?? []).map((p: any) => [p.containerId, p])); + 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 { // one unreachable host shouldn't take down the whole dashboard view return []; @@ -101,6 +120,25 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter { 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 { 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 }); } diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 8344cef..7da4242 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -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; for (const action of dockhandActions) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c8553b2..f5d87d0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -374,6 +374,9 @@ export interface DockhandContainer { status: string; environmentId: number; environmentName: string; + updateAvailable: boolean | null; + newerVersion: string | null; + checkedAt: string | null; } export interface DockhandContainersResponse { @@ -740,6 +743,10 @@ export const api = { `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`, { method: "POST" }, ), + checkForUpdates: (integrationId: number) => + request<{ total: number; updatesFound: number }>(`/api/integrations/${integrationId}/dockhand/check-updates`, { + method: "POST", + }), }, semaphore: { templates: (integrationId: number) => diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index a585c9b..5c99de6 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -201,6 +201,7 @@ interface DockhandSummary { running: number; total: number; hostCount: number; + updatesAvailable: number; stateBreakdown: { label: string; count: number }[]; } @@ -323,6 +324,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { running: res.summary.running, total: res.summary.total, 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), }), ) @@ -607,9 +609,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
0 ? "#f59e0b" : undefined} + label="Updates" + value={dockhandSummary!.updatesAvailable} + accent={dockhandSummary!.updatesAvailable > 0 ? "#f59e0b" : undefined} />
diff --git a/web/src/pages/Docker.tsx b/web/src/pages/Docker.tsx index f441338..f5283d6 100644 --- a/web/src/pages/Docker.tsx +++ b/web/src/pages/Docker.tsx @@ -11,6 +11,16 @@ import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; +function updateBadge(c: DockhandContainer) { + if (c.updateAvailable === null) return —; + if (!c.updateAvailable) return Up to date; + return ( + + Update available + + ); +} + function containerStateBadge(state: string) { switch (state) { case "running": @@ -38,6 +48,7 @@ export default function Docker({ user }: { user: CurrentUser }) { const [loading, setLoading] = useState(false); const [actingOnContainer, setActingOnContainer] = useState(null); const [hostFilter, setHostFilter] = useState(""); + const [checkingUpdates, setCheckingUpdates] = useState(false); useEffect(() => { api.integrations @@ -82,11 +93,32 @@ export default function Docker({ user }: { user: CurrentUser }) { if (!sorted) return; downloadCsv( "docker-containers.csv", - ["Container", "Host", "Image", "State", "Status"], - sorted.map((c) => [c.name, c.environmentName, c.image, c.state, c.status]), + ["Container", "Host", "Image", "State", "Status", "Update available"], + 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") { if (!selectedId) 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 && data.containers.filter((c) => c.updateAvailable).length > 0 && ( + + {data.containers.filter((c) => c.updateAvailable).length} update(s) available + + )}
{hosts.length > 1 && ( @@ -170,6 +207,11 @@ export default function Docker({ user }: { user: CurrentUser }) { + {canEdit && ( + + )}