import { useEffect, useState, type CSSProperties } from "react"; import { api, type CurrentUser, type DockhandContainer, type DockhandContainersResponse, type GiteaRepo, type IntegrationSummary, type IntegrationType, type ProxmoxGuest, type ProxmoxGuestsResponse, type SemaphoreTemplate, type SemaphoreTemplatesResponse, type SynologyStorageResponse, type TailscaleDevice, type TailscaleDevicesResponse, } from "../api/client"; import IntegrationForm from "../components/IntegrationForm"; import IntegrationEditForm from "../components/IntegrationEditForm"; import { formatDateTime } from "../utils/date"; function proxmoxStatusBadge(status: string) { return status === "running" ? ( Running ) : ( Stopped ); } function healthBadge(status: string) { return status === "normal" ? ( Normal ) : ( {status} ); } function formatBytes(bytes: number | null): string { if (bytes === null) return "—"; const units = ["B", "KB", "MB", "GB", "TB", "PB"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } return `${value.toFixed(1)} ${units[unit]}`; } function formatUptime(seconds: number | null): string { if (!seconds) return "—"; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); if (days > 0) return `${days}d ${hours}h`; const minutes = Math.floor((seconds % 3600) / 60); return `${hours}h ${minutes}m`; } function semaphoreStatusBadge(template: SemaphoreTemplate) { const status = template.lastTask?.status; if (!status) return Never run; switch (status) { case "success": return Success; case "error": return Failed; case "stopped": return Stopped; case "rejected": return Rejected; case "waiting": case "starting": case "waiting_confirmation": case "confirmed": return Queued; case "running": case "stopping": return Running; default: return {status}; } } 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", }; function typeBadgeStyle(colors: Record, type: IntegrationType): CSSProperties { const color = colors[type]; if (!color) return {}; return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` }; } 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 [editingIntegration, setEditingIntegration] = useState(null); const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); const [integrationColors, setIntegrationColors] = useState>({}); useEffect(() => { api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {}); }, []); 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); const [semaphoreData, setSemaphoreData] = useState(null); const [semaphoreError, setSemaphoreError] = useState(null); const [loadingTemplates, setLoadingTemplates] = useState(false); const [runningTemplate, setRunningTemplate] = useState(null); const [proxmoxData, setProxmoxData] = useState(null); const [proxmoxError, setProxmoxError] = useState(null); const [loadingGuests, setLoadingGuests] = useState(false); const [actingOnGuest, setActingOnGuest] = useState(null); const [synologyData, setSynologyData] = useState(null); const [synologyError, setSynologyError] = useState(null); const [loadingStorage, setLoadingStorage] = useState(false); 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); } if (selected?.type === "semaphore" && selected.enabled && !managing) { loadSemaphoreTemplates(selected.id); } else { setSemaphoreData(null); } if (selected?.type === "proxmox" && selected.enabled && !managing) { loadProxmoxGuests(selected.id); } else { setProxmoxData(null); } if (selected?.type === "synology" && selected.enabled && !managing) { loadSynologyStorage(selected.id); } else { setSynologyData(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, managing, integrations]); function loadSynologyStorage(id: number) { setLoadingStorage(true); setSynologyError(null); api.integrations.synology .storage(id) .then((res) => setSynologyData(res)) .catch((err) => setSynologyError(err instanceof Error ? err.message : String(err))) .finally(() => setLoadingStorage(false)); } function loadSemaphoreTemplates(id: number) { setLoadingTemplates(true); setSemaphoreError(null); api.integrations.semaphore .templates(id) .then((res) => setSemaphoreData(res)) .catch((err) => setSemaphoreError(err instanceof Error ? err.message : String(err))) .finally(() => setLoadingTemplates(false)); } function loadProxmoxGuests(id: number) { setLoadingGuests(true); setProxmoxError(null); api.integrations.proxmox .guests(id) .then((res) => setProxmoxData(res)) .catch((err) => setProxmoxError(err instanceof Error ? err.message : String(err))) .finally(() => setLoadingGuests(false)); } 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); } } async function runTemplate(t: SemaphoreTemplate) { if (!selectedId) return; if (!confirm(`Run "${t.name}" now?`)) return; setRunningTemplate(t.id); setSemaphoreError(null); try { await api.integrations.semaphore.run(selectedId, t.projectId, t.id); loadSemaphoreTemplates(selectedId); } catch (err) { setSemaphoreError(err instanceof Error ? err.message : String(err)); } finally { setRunningTemplate(null); } } async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") { if (!selectedId) return; if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`)) return; setActingOnGuest(g.vmid); setProxmoxError(null); try { await api.integrations.proxmox[action](selectedId, g.node, g.type, g.vmid); loadProxmoxGuests(selectedId); } catch (err) { setProxmoxError(err instanceof Error ? err.message : String(err)); } finally { setActingOnGuest(null); } } return ( <>

Integrations

{isAdmin && ( )}
{error &&
{error}
} {managing ? ( <> {editingIntegration ? ( { setEditingIntegration(null); loadIntegrations(); }} onCancel={() => setEditingIntegration(null)} /> ) : ( 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 ? formatDateTime(new Date(d.lastSeen)) : "—"}
{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 === "semaphore" ? (

Templates {semaphoreData && semaphoreData.summary.failing > 0 && ( {semaphoreData.summary.failing} failing )}

{semaphoreError &&
{semaphoreError}
}
{canEdit && } {semaphoreData?.templates.map((t) => ( {canEdit && ( )} ))} {semaphoreData?.templates.length === 0 && ( )}
Template Project Last run StatusActions
{t.name} {t.projectName} {t.lastTask ? formatDateTime(new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? "")) : "—"} {semaphoreStatusBadge(t)}
No templates found across any project.
) : selected?.type === "proxmox" ? (

VMs & Containers {proxmoxData && ( {proxmoxData.summary.running}/{proxmoxData.summary.total} running )}

{proxmoxError &&
{proxmoxError}
}
{canEdit && } {proxmoxData?.guests.map((g) => ( {canEdit && ( )} ))} {proxmoxData?.guests.length === 0 && ( )}
Name Node Type Uptime StatusActions
{g.name} #{g.vmid} {g.node} {g.type === "qemu" ? "VM" : "LXC"} {formatUptime(g.uptime)} {proxmoxStatusBadge(g.status)}
{g.status === "running" ? ( <> ) : ( )}
No VMs or containers found across any node.
) : selected?.type === "synology" ? (

Volumes

{synologyError &&
{synologyError}
}
{synologyData?.volumes.map((v) => ( ))} {synologyData?.volumes.length === 0 && ( )}
Volume Type Used Status
{v.id} {v.deviceType} {formatBytes(v.sizeUsed)} / {formatBytes(v.sizeTotal)} {healthBadge(v.status)}
No volumes found.

Disks

{synologyData?.disks.map((d) => ( ))} {synologyData?.disks.length === 0 && ( )}
Disk Device Temp S.M.A.R.T. Status
{d.name} {d.device} {d.temp !== null ? `${d.temp}°C` : "—"} {healthBadge(d.smartStatus)} {healthBadge(d.status)} {d.exceedBadSectorThreshold && Bad sectors} {d.belowRemainLifeThreshold && Low life}
No disks found.
) : (
{selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."}
)} )} )} ); }