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 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; const state = run.conclusion || run.status; switch (state) { case "success": return Success; case "failure": return Failed; case "cancelled": return Cancelled; case "skipped": return Skipped; case "waiting": case "queued": case "pending": return Queued; case "running": case "in_progress": return Running; default: return {state}; } } const TYPE_LABELS: Record = { 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(null); const [managing, setManaging] = useState(false); const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); const [tailscaleData, setTailscaleData] = useState(null); const [tailscaleError, setTailscaleError] = useState(null); const [loadingDevices, setLoadingDevices] = useState(false); const [giteaRepos, setGiteaRepos] = useState(null); const [giteaError, setGiteaError] = useState(null); 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() .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 ( <>

Integrations

{isAdmin && ( )}
{error &&
{error}
} {managing ? ( <> setManaging(false)} />
{integrations?.map((i) => ( ))} {integrations?.length === 0 && ( )}
Name Type Status Actions
{i.name} {TYPE_LABELS[i.type]}
No integrations configured yet.
) : ( <> {integrations?.length === 0 ? (
No integrations configured yet. {isAdmin && ( <> {" "} . )}
) : ( <>
{selected?.type === "tailscale" ? (

Devices {tailscaleData && ( {tailscaleData.summary.online}/{tailscaleData.summary.total} online {tailscaleData.summary.unauthorized > 0 && `, ${tailscaleData.summary.unauthorized} awaiting authorization`} )}

{tailscaleError &&
{tailscaleError}
}
{canEdit && } {tailscaleData?.devices.map((d) => ( {canEdit && ( )} ))} {tailscaleData?.devices.length === 0 && ( )}
Device Address OS Status Last seenActions
{d.label || d.hostname} {d.isExitNode && Exit node} {d.primaryAddress} {d.os || "—"} {d.online ? ( Online ) : ( Offline )} {!d.authorized && Unauthorized} {d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "—"}
{d.authorized ? ( ) : ( )}
No devices in this tailnet.
) : selected?.type === "gitea" ? (

Repositories

{giteaError &&
{giteaError}
}
{canEdit && } {giteaRepos?.map((r) => ( {canEdit && ( )} ))} {giteaRepos?.length === 0 && ( )}
Repository Last run Branch StatusActions
{r.fullName} {r.private && Private} {r.latestRun ? ( #{r.latestRun.runNumber} {r.latestRun.displayTitle} ) : r.hasActions ? ( "—" ) : ( "Actions disabled" )} {r.latestRun?.headBranch ?? "—"} {runStatusBadge(r)} {r.latestRun && (r.latestRun.conclusion === "failure" || r.latestRun.status === "failure") && ( )}
No repositories visible to this token.
) : selected?.type === "dockhand" ? (

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

{dockhandError &&
{dockhandError}
}
{canEdit && } {dockhandData?.containers.map((c) => ( {canEdit && ( )} ))} {dockhandData?.containers.length === 0 && ( )}
Container Host Image StatusActions
{c.name} {c.environmentName} {c.image} {containerStateBadge(c.state)}
{c.status}
{c.state === "running" ? ( <> ) : ( )}
No containers found across any environment.
) : (
{selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."}
)} )} )} ); }