diff --git a/server/src/integrations/dockhand/adapter.ts b/server/src/integrations/dockhand/adapter.ts new file mode 100644 index 0000000..0ea3ebe --- /dev/null +++ b/server/src/integrations/dockhand/adapter.ts @@ -0,0 +1,126 @@ +/** + * Dockhand adapter — uses Dockhand's own aggregating REST API (not the raw + * Docker Engine API on each host directly). Requires config: url, token + * + * Dockhand is a multi-host Docker manager; "environments" are the individual + * Docker hosts/agents it's connected to, and containers are listed/controlled + * per environment. + * + * API reference verified against Dockhand's published OpenAPI spec + * (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json). + */ + +export interface DockhandConfig { + url: string; + token: string; +} + +export interface DockhandEnvironment { + id: number; + name: string; + connectionType: string; +} + +export interface DockhandContainer { + id: string; + name: string; + image: string; + state: string; // "running" | "exited" | "paused" | "restarting" | "created" | "dead" + status: string; // human string, e.g. "Up 2 hours (healthy)" + environmentId: number; + environmentName: string; +} + +export interface DockhandAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + listContainers(): Promise; + startContainer(environmentId: number, containerId: string): Promise; + stopContainer(environmentId: number, containerId: string): Promise; + restartContainer(environmentId: number, containerId: string): Promise; +} + +export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter { + function base() { + return config.url.replace(/\/$/, ""); + } + + function headers() { + return { + Authorization: `Bearer ${config.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }; + } + + async function api(method: string, path: string): Promise { + const res = await fetch(`${base()}${path}`, { method, headers: headers() }); + const text = await res.text(); + let data: any = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + // non-JSON error page + } + if (!res.ok) { + throw new Error(data?.message || data?.error || `Dockhand API error: HTTP ${res.status}`); + } + return data; + } + + async function listEnvironments(): Promise { + const data = await api("GET", "/api/environments"); + return (Array.isArray(data) ? data : []).map((e: any) => ({ + id: e.id, + name: e.name, + connectionType: e.connectionType, + })); + } + + async function listContainers(): Promise { + const environments = await listEnvironments(); + 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, + })); + } catch { + // one unreachable host shouldn't take down the whole dashboard view + return []; + } + }), + ); + return perEnv.flat(); + } + + async function startContainer(environmentId: number, containerId: string): Promise { + await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`); + } + + async function stopContainer(environmentId: number, containerId: string): Promise { + await api("POST", `/api/containers/${encodeURIComponent(containerId)}/stop?env=${environmentId}`); + } + + async function restartContainer(environmentId: number, containerId: string): Promise { + await api("POST", `/api/containers/${encodeURIComponent(containerId)}/restart?env=${environmentId}`); + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await api("GET", "/api/environments"); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return { ping, listContainers, startContainer, stopContainer, restartContainer }; +} diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index d7754b0..dab9e74 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -19,6 +19,10 @@ export const INTEGRATION_FIELDS: Partial; @@ -20,6 +21,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat return createTailscaleAdapter(config as any); case "gitea": return createGiteaAdapter(config as any); + case "dockhand": + return createDockhandAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index f11e1db..f02e026 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -16,6 +16,7 @@ import { createIntegrationAdapter } from "../integrations/registry.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; import { createGiteaAdapter } from "../integrations/gitea/adapter.js"; +import { createDockhandAdapter } from "../integrations/dockhand/adapter.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const integrationsRouter = Router(); @@ -397,3 +398,74 @@ integrationsRouter.post( } }), ); + +// ─── Dockhand ──────────────────────────────────────────────────────────────── + +async function requireDockhandAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "dockhand") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + return { integration: loaded.integration, adapter: createDockhandAdapter(loaded.config as any) }; +} + +integrationsRouter.get("/:id/dockhand/containers", asyncHandler(async (req, res) => { + const found = await requireDockhandAdapter(req, res); + if (!found) return; + + try { + const containers = await found.adapter.listContainers(); + res.json({ + containers, + summary: { + total: containers.length, + running: containers.filter((c) => c.state === "running").length, + }, + }); + } 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) { + integrationsRouter.post( + `/:id/dockhand/environments/:envId/containers/:containerId/${action}`, + requireRole("operator"), + asyncHandler(async (req, res) => { + const found = await requireDockhandAdapter(req, res); + if (!found) return; + + const envId = Number(req.params.envId); + if (!Number.isInteger(envId)) { + return res.status(400).json({ error: "invalid_environment_id" }); + } + + try { + await found.adapter[`${action}Container`](envId, req.params.containerId); + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: `${action}_container`, + targetType: "dockhand_container", + targetId: req.params.containerId, + detail: { integrationId: found.integration.id, environmentId: envId }, + }); + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + }), + ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 92f2553..f927122 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/api/integrations/${integrationId}/dockhand/containers`), + start: (integrationId: number, envId: number, containerId: string) => + request( + `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/start`, + { method: "POST" }, + ), + stop: (integrationId: number, envId: number, containerId: string) => + request( + `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/stop`, + { method: "POST" }, + ), + restart: (integrationId: number, envId: number, containerId: string) => + request( + `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`, + { method: "POST" }, + ), + }, }, }; diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 7bf4166..795bc91 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -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(null); const [tailscaleSummary, setTailscaleSummary] = useState(null); const [giteaSummary, setGiteaSummary] = useState(null); + const [dockhandSummary, setDockhandSummary] = useState(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 ( <>
@@ -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 (
@@ -107,6 +126,13 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{giteaSummary!.failing} with a failing build
)}
+ ) : isLiveDockhand ? ( +
+
+ {dockhandSummary!.running}/{dockhandSummary!.total} +
+
containers running
+
) : (
{integration ? ( diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 1f203d3..dae8451 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -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 Running; + case "exited": + case "dead": + return {state === "dead" ? "Dead" : "Exited"}; + case "paused": + return Paused; + case "restarting": + return Restarting; + default: + return {state}; + } +} + function runStatusBadge(repo: GiteaRepo) { const run = repo.latestRun; if (!run) return No runs; @@ -62,6 +80,11 @@ export default function Integrations({ user }: { user: CurrentUser }) { const [loadingRepos, setLoadingRepos] = useState(false); const [rerunningRun, setRerunningRun] = useState(null); + const [dockhandData, setDockhandData] = useState(null); + const [dockhandError, setDockhandError] = useState(null); + const [loadingContainers, setLoadingContainers] = useState(false); + const [actingOnContainer, setActingOnContainer] = useState(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 ( <>
@@ -406,6 +459,94 @@ export default function Integrations({ user }: { user: CurrentUser }) {
+ ) : selected?.type === "dockhand" ? ( +
+
+

+ Containers + {dockhandData && ( + + {dockhandData.summary.running}/{dockhandData.summary.total} running + + )} +

+
+ +
+
+ {dockhandError &&
{dockhandError}
} +
+ + + + + + + + {canEdit && } + + + + {dockhandData?.containers.map((c) => ( + + + + + + {canEdit && ( + + )} + + ))} + {dockhandData?.containers.length === 0 && ( + + + + )} + +
ContainerHostImageStatusActions
{c.name}{c.environmentName}{c.image} + {containerStateBadge(c.state)} +
{c.status}
+
+
+ {c.state === "running" ? ( + <> + + + + ) : ( + + )} +
+
+ No containers found across any environment. +
+
+
) : (