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
+135
-12
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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,8 +88,17 @@ 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`),
|
||||||
|
// 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,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
image: c.image,
|
image: c.image,
|
||||||
@@ -91,7 +106,11 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
|
|||||||
status: c.status,
|
status: c.status,
|
||||||
environmentId: env.id,
|
environmentId: env.id,
|
||||||
environmentName: env.name,
|
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 });
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user