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>
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import {
|
|
api,
|
|
type CurrentUser,
|
|
type DockhandContainer,
|
|
type DockhandContainersResponse,
|
|
type IntegrationSummary,
|
|
} from "../api/client";
|
|
import { useSortable } from "../hooks/useSortable";
|
|
import SortableTh from "../components/SortableTh";
|
|
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) {
|
|
switch (state) {
|
|
case "running":
|
|
return <span className="badge bg-green-lt text-green">Running</span>;
|
|
case "exited":
|
|
case "dead":
|
|
return <span className="badge bg-red-lt text-red">{state === "dead" ? "Dead" : "Exited"}</span>;
|
|
case "paused":
|
|
return <span className="badge bg-yellow-lt text-yellow">Paused</span>;
|
|
case "restarting":
|
|
return <span className="badge bg-blue-lt text-blue">Restarting</span>;
|
|
default:
|
|
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
|
|
}
|
|
}
|
|
|
|
export default function Docker({ user }: { user: CurrentUser }) {
|
|
const canEdit = user.role === "admin" || user.role === "operator";
|
|
|
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [data, setData] = useState<DockhandContainersResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [actingOnContainer, setActingOnContainer] = useState<string | null>(null);
|
|
const [hostFilter, setHostFilter] = useState("");
|
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
|
|
|
useEffect(() => {
|
|
api.integrations
|
|
.list()
|
|
.then((res) => {
|
|
const dockhandIntegrations = res.integrations.filter((i) => i.type === "dockhand");
|
|
setIntegrations(dockhandIntegrations);
|
|
if (!selectedId && dockhandIntegrations.length > 0) setSelectedId(dockhandIntegrations[0].id);
|
|
})
|
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
|
|
|
function loadContainers(id: number) {
|
|
setLoading(true);
|
|
setError(null);
|
|
api.integrations.dockhand
|
|
.containers(id)
|
|
.then((res) => setData(res))
|
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
|
.finally(() => setLoading(false));
|
|
}
|
|
|
|
useEffect(() => {
|
|
setHostFilter("");
|
|
if (selected?.enabled) {
|
|
loadContainers(selected.id);
|
|
} else {
|
|
setData(null);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedId]);
|
|
|
|
const hosts = [...new Set((data?.containers ?? []).map((c) => c.environmentName))].sort();
|
|
const filteredContainers = (data?.containers ?? []).filter((c) => !hostFilter || c.environmentName === hostFilter);
|
|
|
|
const { sorted, sortKey, sortDir, requestSort } = useSortable(filteredContainers);
|
|
|
|
function exportCsv() {
|
|
if (!sorted) return;
|
|
downloadCsv(
|
|
"docker-containers.csv",
|
|
["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;
|
|
setActingOnContainer(c.id);
|
|
setError(null);
|
|
try {
|
|
await api.integrations.dockhand[action](selectedId, c.environmentId, c.id);
|
|
loadContainers(selectedId);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setActingOnContainer(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h2 className="page-title mb-3">Docker</h2>
|
|
{error && <div className="alert alert-danger">{error}</div>}
|
|
|
|
{integrations?.length === 0 ? (
|
|
<div className="card">
|
|
<div className="card-body text-secondary">
|
|
No Dockhand integration configured yet.{" "}
|
|
<Link to="/integrations">Add one under Integrations</Link>.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{integrations && integrations.length > 1 && (
|
|
<div className="mb-3" style={{ maxWidth: 320 }}>
|
|
<select
|
|
className="form-select"
|
|
value={selectedId ?? ""}
|
|
onChange={(e) => setSelectedId(Number(e.target.value))}
|
|
>
|
|
{integrations.map((i) => (
|
|
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
|
{i.name}
|
|
{!i.enabled ? " — disabled" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{selected && !selected.enabled ? (
|
|
<div className="card">
|
|
<div className="card-body text-secondary">
|
|
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
|
|
containers.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
Containers
|
|
{data && (
|
|
<span className="text-secondary fw-normal ms-2">
|
|
{data.summary.running}/{data.summary.total} running
|
|
</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>
|
|
<div className="card-actions d-flex gap-2">
|
|
{hosts.length > 1 && (
|
|
<select
|
|
className="form-select form-select-sm"
|
|
style={{ width: "auto" }}
|
|
value={hostFilter}
|
|
onChange={(e) => setHostFilter(e.target.value)}
|
|
>
|
|
<option value="">All hosts</option>
|
|
{hosts.map((host) => (
|
|
<option key={host} value={host}>
|
|
{host}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
|
|
Export CSV
|
|
</button>
|
|
{canEdit && (
|
|
<button className="btn btn-sm btn-outline-secondary" onClick={checkForUpdates} disabled={checkingUpdates}>
|
|
{checkingUpdates ? "Checking…" : "Check for updates"}
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => selected && loadContainers(selected.id)}
|
|
disabled={loading}
|
|
>
|
|
{loading ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-responsive">
|
|
<table className="table table-vcenter card-table">
|
|
<thead>
|
|
<tr>
|
|
<SortableTh<DockhandContainer> label="Container" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<SortableTh<DockhandContainer>
|
|
label="Host"
|
|
sortKeyName="environmentName"
|
|
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="Update"
|
|
sortKeyName="updateAvailable"
|
|
activeKey={sortKey}
|
|
direction={sortDir}
|
|
onSort={requestSort}
|
|
/>
|
|
{canEdit && <th className="w-1">Actions</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted?.map((c) => (
|
|
<tr key={`${c.environmentId}:${c.id}`}>
|
|
<td>{c.name}</td>
|
|
<td className="text-secondary">{c.environmentName}</td>
|
|
<td className="text-secondary">{c.image}</td>
|
|
<td>
|
|
{containerStateBadge(c.state)}
|
|
<div className="text-secondary small">{c.status}</div>
|
|
</td>
|
|
<td>{updateBadge(c)}</td>
|
|
{canEdit && (
|
|
<td>
|
|
<div className="btn-list flex-nowrap">
|
|
{c.state === "running" ? (
|
|
<>
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={() => containerAction(c, "restart")}
|
|
disabled={actingOnContainer === c.id}
|
|
>
|
|
Restart
|
|
</button>
|
|
<button
|
|
className="btn btn-sm btn-outline-danger"
|
|
onClick={() => containerAction(c, "stop")}
|
|
disabled={actingOnContainer === c.id}
|
|
>
|
|
Stop
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
className="btn btn-sm btn-primary"
|
|
onClick={() => containerAction(c, "start")}
|
|
disabled={actingOnContainer === c.id}
|
|
>
|
|
Start
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
{data && sorted?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
|
{hostFilter ? `No containers found on "${hostFilter}".` : "No containers found across any environment."}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|