Add Dockhand integration

Third live integration: container status across every Docker host Dockhand
manages, with start/stop/restart actions — matching the "dashboard + basic
actions" depth from the plan. Talks to Dockhand's own aggregating REST API
(bearer tokens, dh_...) rather than the raw Docker Engine API on each host
directly, so one credential covers every host Dockhand is already connected
to.

Adapter built against Dockhand's real published OpenAPI spec (fetched from
https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json,
257 documented paths) since the instance itself isn't reachable from here —
same "verify against the real shape, don't guess" approach as Gitea, just
via the spec instead of a live instance:
- GET /api/environments — the Docker hosts Dockhand knows about
- GET /api/containers?env=<id>&all=true — containers per host
  ({id, name, image, state, status})
- POST /api/containers/{id}/{start,stop,restart}?env=<id>

server/src/integrations/dockhand/adapter.ts fans the environments call out to
one containers call per host (Promise.all) and flattens the result, tagging
each container with its environment; one unreachable host returns an empty
list for that host rather than failing the whole dashboard view. Follows the
same config-in-UI + encrypted-credential pattern as Tailscale and Gitea.

Verified: full build passes. Since Dockhand isn't reachable from here, ran a
14-check HTTP test against the live server instead of the real API — role
gating, credential non-leakage, disabled-integration blocking, and
(critically) re-confirmed the wrong_type crash-safety pattern holds for this
third adapter type: hitting the Dockhand routes on a differently-typed
integration row returns a clean 400 rather than crashing the process, and
the server keeps responding to /health afterward. Real container data and
the start/stop/restart actions still need verification once this app runs
on the user's LAN where Dockhand is reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 00:04:34 +02:00
1 parent f54709c7a8
commit 9c45a806c8
7 files changed
+407 -1

No files matched your search

+34
View File
@@ -222,6 +222,21 @@ export interface GiteaRepo {
latestRun: GiteaWorkflowRun | null;
}
export interface DockhandContainer {
id: string;
name: string;
image: string;
state: string;
status: string;
environmentId: number;
environmentName: string;
}
export interface DockhandContainersResponse {
containers: DockhandContainer[];
summary: { total: number; running: number };
}
export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}
@@ -400,5 +415,24 @@ export const api = {
{ method: "POST" },
),
},
dockhand: {
containers: (integrationId: number) =>
request<DockhandContainersResponse>(`/api/integrations/${integrationId}/dockhand/containers`),
start: (integrationId: number, envId: number, containerId: string) =>
request<void>(
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/start`,
{ method: "POST" },
),
stop: (integrationId: number, envId: number, containerId: string) =>
request<void>(
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/stop`,
{ method: "POST" },
),
restart: (integrationId: number, envId: number, containerId: string) =>
request<void>(
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`,
{ method: "POST" },
),
},
},
};
+27 -1
View File
@@ -16,10 +16,16 @@ interface GiteaSummary {
failing: number;
}
interface DockhandSummary {
running: number;
total: number;
}
export default function Dashboard({ user }: { user: CurrentUser }) {
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
useEffect(() => {
api.integrations.list().then((res) => setIntegrations(res.integrations));
@@ -54,6 +60,18 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
.catch(() => setGiteaSummary(null));
}, [integrations]);
useEffect(() => {
const dockhand = integrations?.find((i) => i.type === "dockhand" && i.enabled);
if (!dockhand) {
setDockhandSummary(null);
return;
}
api.integrations.dockhand
.containers(dockhand.id)
.then((res) => setDockhandSummary({ running: res.summary.running, total: res.summary.total }))
.catch(() => setDockhandSummary(null));
}, [integrations]);
return (
<>
<div className="row row-cards mb-3">
@@ -71,7 +89,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const integration = integrations?.find((i) => i.type === type && i.enabled);
const isLiveTailscale = type === "tailscale" && integration && tailscaleSummary;
const isLiveGitea = type === "gitea" && integration && giteaSummary;
const isLive = isLiveTailscale || isLiveGitea;
const isLiveDockhand = type === "dockhand" && integration && dockhandSummary;
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand;
return (
<div className="col-sm-6 col-lg-4" key={type}>
@@ -107,6 +126,13 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
)}
</div>
) : isLiveDockhand ? (
<div className="mt-2">
<div className="h3 mb-0">
{dockhandSummary!.running}/{dockhandSummary!.total}
</div>
<div className="text-secondary">containers running</div>
</div>
) : (
<div className="text-secondary mt-2">
{integration ? (
+141
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
import {
api,
type CurrentUser,
type DockhandContainer,
type DockhandContainersResponse,
type GiteaRepo,
type IntegrationSummary,
type IntegrationType,
@@ -10,6 +12,22 @@ import {
} from "../api/client";
import IntegrationForm from "../components/IntegrationForm";
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>;
}
}
function runStatusBadge(repo: GiteaRepo) {
const run = repo.latestRun;
if (!run) return <span className="badge bg-secondary-lt text-secondary">No runs</span>;
@@ -62,6 +80,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
const [loadingRepos, setLoadingRepos] = useState(false);
const [rerunningRun, setRerunningRun] = useState<number | null>(null);
const [dockhandData, setDockhandData] = useState<DockhandContainersResponse | null>(null);
const [dockhandError, setDockhandError] = useState<string | null>(null);
const [loadingContainers, setLoadingContainers] = useState(false);
const [actingOnContainer, setActingOnContainer] = useState<string | null>(null);
function loadIntegrations() {
api.integrations
.list()
@@ -96,6 +119,16 @@ export default function Integrations({ user }: { user: CurrentUser }) {
.finally(() => setLoadingRepos(false));
}
function loadDockhandContainers(id: number) {
setLoadingContainers(true);
setDockhandError(null);
api.integrations.dockhand
.containers(id)
.then((res) => setDockhandData(res))
.catch((err) => setDockhandError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingContainers(false));
}
useEffect(() => {
if (selected?.type === "tailscale" && selected.enabled && !managing) {
loadTailscaleDevices(selected.id);
@@ -107,6 +140,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
} else {
setGiteaRepos(null);
}
if (selected?.type === "dockhand" && selected.enabled && !managing) {
loadDockhandContainers(selected.id);
} else {
setDockhandData(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId, managing, integrations]);
@@ -165,6 +203,21 @@ export default function Integrations({ user }: { user: CurrentUser }) {
}
}
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
if (!selectedId) return;
if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return;
setActingOnContainer(c.id);
setDockhandError(null);
try {
await api.integrations.dockhand[action](selectedId, c.environmentId, c.id);
loadDockhandContainers(selectedId);
} catch (err) {
setDockhandError(err instanceof Error ? err.message : String(err));
} finally {
setActingOnContainer(null);
}
}
return (
<>
<div className="d-flex align-items-center mb-3">
@@ -406,6 +459,94 @@ export default function Integrations({ user }: { user: CurrentUser }) {
</table>
</div>
</div>
) : selected?.type === "dockhand" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">
Containers
{dockhandData && (
<span className="text-secondary fw-normal ms-2">
{dockhandData.summary.running}/{dockhandData.summary.total} running
</span>
)}
</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => loadDockhandContainers(selected.id)}
disabled={loadingContainers}
>
{loadingContainers ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{dockhandError && <div className="alert alert-danger m-3 mb-0">{dockhandError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Container</th>
<th>Host</th>
<th>Image</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{dockhandData?.containers.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>
{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>
))}
{dockhandData?.containers.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No containers found across any environment.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="card">
<div className="card-body text-secondary">