Files
Homelab-manager/web/src/pages/Integrations.tsx
T
bobbanandClaude Sonnet 5 9c45a806c8 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>
2026-09-15 00:04:34 +02:00

564 lines
23 KiB
TypeScript

import { useEffect, useState } from "react";
import {
api,
type CurrentUser,
type DockhandContainer,
type DockhandContainersResponse,
type GiteaRepo,
type IntegrationSummary,
type IntegrationType,
type TailscaleDevice,
type TailscaleDevicesResponse,
} 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>;
const state = run.conclusion || run.status;
switch (state) {
case "success":
return <span className="badge bg-green-lt text-green">Success</span>;
case "failure":
return <span className="badge bg-red-lt text-red">Failed</span>;
case "cancelled":
return <span className="badge bg-secondary-lt text-secondary">Cancelled</span>;
case "skipped":
return <span className="badge bg-secondary-lt text-secondary">Skipped</span>;
case "waiting":
case "queued":
case "pending":
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
case "running":
case "in_progress":
return <span className="badge bg-blue-lt text-blue">Running</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
}
}
const TYPE_LABELS: Record<IntegrationType, string> = {
tailscale: "Tailscale",
proxmox: "Proxmox",
synology: "Synology",
semaphore: "Semaphore",
gitea: "Gitea",
dockhand: "Dockhand",
};
export default function Integrations({ user }: { user: CurrentUser }) {
const isAdmin = user.role === "admin";
const canEdit = user.role === "admin" || user.role === "operator";
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [managing, setManaging] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [tailscaleData, setTailscaleData] = useState<TailscaleDevicesResponse | null>(null);
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
const [loadingDevices, setLoadingDevices] = useState(false);
const [giteaRepos, setGiteaRepos] = useState<GiteaRepo[] | null>(null);
const [giteaError, setGiteaError] = useState<string | null>(null);
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()
.then((res) => {
setIntegrations(res.integrations);
if (!selectedId && res.integrations.length > 0) setSelectedId(res.integrations[0].id);
})
.catch((err) => setError(String(err)));
}
useEffect(loadIntegrations, []);
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
function loadTailscaleDevices(id: number) {
setLoadingDevices(true);
setTailscaleError(null);
api.integrations.tailscale
.devices(id)
.then((res) => setTailscaleData(res))
.catch((err) => setTailscaleError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingDevices(false));
}
function loadGiteaRepos(id: number) {
setLoadingRepos(true);
setGiteaError(null);
api.integrations.gitea
.repos(id)
.then((res) => setGiteaRepos(res.repos))
.catch((err) => setGiteaError(err instanceof Error ? err.message : String(err)))
.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);
} else {
setTailscaleData(null);
}
if (selected?.type === "gitea" && selected.enabled && !managing) {
loadGiteaRepos(selected.id);
} 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]);
async function toggleEnabled(i: IntegrationSummary) {
try {
await api.integrations.update(i.id, { enabled: !i.enabled });
loadIntegrations();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
async function removeIntegration(i: IntegrationSummary) {
if (!confirm(`Delete integration "${i.name}"?`)) return;
try {
await api.integrations.remove(i.id);
if (selectedId === i.id) setSelectedId(null);
loadIntegrations();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
async function setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) {
if (!selectedId) return;
try {
await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized);
loadTailscaleDevices(selectedId);
} catch (err) {
setTailscaleError(err instanceof Error ? err.message : String(err));
}
}
async function removeDevice(device: TailscaleDevice) {
if (!selectedId) return;
if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return;
try {
await api.integrations.tailscale.remove(selectedId, device.id);
loadTailscaleDevices(selectedId);
} catch (err) {
setTailscaleError(err instanceof Error ? err.message : String(err));
}
}
async function rerunFailed(repo: GiteaRepo) {
if (!selectedId || !repo.latestRun) return;
setRerunningRun(repo.latestRun.id);
setGiteaError(null);
try {
await api.integrations.gitea.rerunFailed(selectedId, repo.owner, repo.name, repo.latestRun.id);
loadGiteaRepos(selectedId);
} catch (err) {
setGiteaError(err instanceof Error ? err.message : String(err));
} finally {
setRerunningRun(null);
}
}
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">
<h2 className="page-title mb-0">Integrations</h2>
{isAdmin && (
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManaging((v) => !v)}>
{managing ? "Back to browsing" : "Manage integrations"}
</button>
)}
</div>
{error && <div className="alert alert-danger">{error}</div>}
{managing ? (
<>
<IntegrationForm onCreated={loadIntegrations} onCancel={() => setManaging(false)} />
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Status</th>
<th className="w-1">Actions</th>
</tr>
</thead>
<tbody>
{integrations?.map((i) => (
<tr key={i.id}>
<td>{i.name}</td>
<td>{TYPE_LABELS[i.type]}</td>
<td>
<button
className={`badge ${i.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
onClick={() => toggleEnabled(i)}
>
{i.enabled ? "Enabled" : "Disabled"}
</button>
</td>
<td>
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
Delete
</button>
</td>
</tr>
))}
{integrations?.length === 0 && (
<tr>
<td colSpan={4} className="text-secondary text-center">
No integrations configured yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
) : (
<>
{integrations?.length === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No integrations configured yet.
{isAdmin && (
<>
{" "}
<button className="btn btn-link p-0 align-baseline" onClick={() => setManaging(true)}>
Add one
</button>
.
</>
)}
</div>
</div>
) : (
<>
<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} ({TYPE_LABELS[i.type]}){!i.enabled ? " — disabled" : ""}
</option>
))}
</select>
</div>
{selected?.type === "tailscale" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">
Devices
{tailscaleData && (
<span className="text-secondary fw-normal ms-2">
{tailscaleData.summary.online}/{tailscaleData.summary.total} online
{tailscaleData.summary.unauthorized > 0 && `, ${tailscaleData.summary.unauthorized} awaiting authorization`}
</span>
)}
</h3>
<div className="card-actions">
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadTailscaleDevices(selected.id)} disabled={loadingDevices}>
{loadingDevices ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{tailscaleError && <div className="alert alert-danger m-3 mb-0">{tailscaleError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Device</th>
<th>Address</th>
<th>OS</th>
<th>Status</th>
<th>Last seen</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{tailscaleData?.devices.map((d) => (
<tr key={d.id}>
<td>
{d.label || d.hostname}
{d.isExitNode && <span className="badge bg-purple-lt ms-2">Exit node</span>}
</td>
<td className="text-secondary">{d.primaryAddress}</td>
<td>{d.os || "—"}</td>
<td>
{d.online ? (
<span className="badge bg-green-lt text-green me-1">Online</span>
) : (
<span className="badge bg-secondary-lt text-secondary me-1">Offline</span>
)}
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
</td>
<td className="text-secondary">{d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "—"}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
{d.authorized ? (
<button className="btn btn-sm" onClick={() => setDeviceAuthorized(d, false)}>
Deauthorize
</button>
) : (
<button className="btn btn-sm btn-primary" onClick={() => setDeviceAuthorized(d, true)}>
Authorize
</button>
)}
<button className="btn btn-sm btn-outline-danger" onClick={() => removeDevice(d)}>
Remove
</button>
</div>
</td>
)}
</tr>
))}
{tailscaleData?.devices.length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
No devices in this tailnet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : selected?.type === "gitea" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">Repositories</h3>
<div className="card-actions">
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadGiteaRepos(selected.id)} disabled={loadingRepos}>
{loadingRepos ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{giteaError && <div className="alert alert-danger m-3 mb-0">{giteaError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Repository</th>
<th>Last run</th>
<th>Branch</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{giteaRepos?.map((r) => (
<tr key={r.fullName}>
<td>
<a href={r.htmlUrl} target="_blank" rel="noopener noreferrer">
{r.fullName}
</a>
{r.private && <span className="badge bg-secondary-lt ms-2">Private</span>}
</td>
<td className="text-secondary">
{r.latestRun ? (
<a href={r.latestRun.htmlUrl} target="_blank" rel="noopener noreferrer">
#{r.latestRun.runNumber} {r.latestRun.displayTitle}
</a>
) : r.hasActions ? (
"—"
) : (
"Actions disabled"
)}
</td>
<td className="text-secondary">{r.latestRun?.headBranch ?? "—"}</td>
<td>{runStatusBadge(r)}</td>
{canEdit && (
<td>
{r.latestRun && (r.latestRun.conclusion === "failure" || r.latestRun.status === "failure") && (
<button
className="btn btn-sm"
onClick={() => rerunFailed(r)}
disabled={rerunningRun === r.latestRun.id}
>
{rerunningRun === r.latestRun.id ? "Re-running…" : "Re-run failed"}
</button>
)}
</td>
)}
</tr>
))}
{giteaRepos?.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No repositories visible to this token.
</td>
</tr>
)}
</tbody>
</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">
{selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."}
</div>
</div>
)}
</>
)}
</>
)}
</>
);
}