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:
1 parent
7564796c39
commit
f2a6253ddf
6 files changed
+142
-19
No files matched your search
@@ -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<void>;
|
||||
stopContainer(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 {
|
||||
@@ -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<string, any>((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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user