From 8fef68c27aae9016dbc82f9d3cd2e04dc6e27862 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Wed, 16 Sep 2026 00:09:02 +0200 Subject: [PATCH] Move Tailscale, Semaphore, and Gitea to their own top-level pages Same move as Docker: each was buried inside the generic Integrations browsing view, sharing one "pick any integration" dropdown across six unrelated types. Give them dedicated pages instead. New web/src/pages/{Tailscale,Semaphore,Gitea}.tsx each reuse the existing, unchanged API routes and adapters (no server changes) with their own integration picker scoped to just that type. Added matching nav items (Tailscale, Semaphore, Gitea, right after Docker) and routes. Removed the corresponding state/handlers/tables from Integrations.tsx entirely (374 lines). Adding, editing, enabling/disabling, and deleting each credential itself still happens under Integrations -> Manage integrations, same as Proxmox and Synology, which stay on the Integrations page. Selecting one of these three types in Integrations' generic browsing dropdown now points to its new page instead of rendering a table there too. Co-Authored-By: Claude Sonnet 5 --- README.md | 33 +-- web/src/App.tsx | 6 + web/src/layout/AppShell.tsx | 6 + web/src/pages/Gitea.tsx | 203 ++++++++++++++++++ web/src/pages/Integrations.tsx | 374 +-------------------------------- web/src/pages/Semaphore.tsx | 204 ++++++++++++++++++ web/src/pages/Tailscale.tsx | 202 ++++++++++++++++++ 7 files changed, 646 insertions(+), 382 deletions(-) create mode 100644 web/src/pages/Gitea.tsx create mode 100644 web/src/pages/Semaphore.tsx create mode 100644 web/src/pages/Tailscale.tsx diff --git a/README.md b/README.md index 82ce16a..5dfccd9 100644 --- a/README.md +++ b/README.md @@ -34,20 +34,25 @@ All modules from the original plan are built: Proxmox for VM/LXC-backed servers, or from the agent's own hardware report for everything else. Proxmox-linked servers also get start/stop/restart buttons right on the detail page. -- **Integrations → Tailscale** — device list with online/authorized status, - and authorize/deauthorize/remove actions; a live device-count widget. -- **Integrations → Gitea** — repo list with each repo's last CI run status, - and re-running just the failed jobs in a run; a live repo-count widget - (with a failing-build warning). -- **Docker** (its own top-level page, backed by the Dockhand - integration) — container status across every Docker host Dockhand - manages (one credential covers all of them), with start/stop/restart - actions and a live running/total widget. Add/edit/remove the - Dockhand credential itself under Integrations → Manage integrations - as with every other integration. -- **Integrations → Semaphore** — Ansible run status per template across - every project, with a "Run" action to trigger a template; a live - template-count widget (with a last-failed warning). +- **Tailscale**, **Semaphore**, **Gitea**, and **Docker** each get their + own top-level page (backed by the matching integration) instead of + living inside a shared Integrations browsing view: + - **Tailscale** — device list with online/authorized status, and + authorize/deauthorize/remove actions; a live device-count widget. + - **Semaphore** — Ansible run status per template across every + project, with a "Run" action to trigger a template; a live + template-count widget (with a last-failed warning). + - **Gitea** — repo list with each repo's last CI run status, and + re-running just the failed jobs in a run; a live repo-count widget + (with a failing-build warning). + - **Docker** — container status across every Docker host Dockhand + manages (one credential covers all of them), with + start/stop/restart actions, a host filter, and a live + running/total widget. + + Add/edit/enable/disable/remove each integration's credential itself + under Integrations → Manage integrations, same as Proxmox and + Synology (which stay on the Integrations page). - **Integrations → Proxmox** — VM/LXC status across every node in the cluster, with start/stop/restart actions; a live running/total widget. Supports self-signed certificates (common in homelab Proxmox setups). diff --git a/web/src/App.tsx b/web/src/App.tsx index feef29e..1761646 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,9 @@ import ServersTasks from "./pages/ServersTasks"; import ServerDetail from "./pages/ServerDetail"; import Integrations from "./pages/Integrations"; import Docker from "./pages/Docker"; +import Tailscale from "./pages/Tailscale"; +import Semaphore from "./pages/Semaphore"; +import Gitea from "./pages/Gitea"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; @@ -74,6 +77,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> }, { to: "/integrations", label: "Integrations", icon: }, { to: "/docker", label: "Docker", icon: }, + { to: "/tailscale", label: "Tailscale", icon: }, + { to: "/semaphore", label: "Semaphore", icon: }, + { to: "/gitea", label: "Gitea", icon: }, { to: "/users", label: "Users", icon: , minRole: "admin" }, { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, { to: "/settings", label: "Settings", icon: , minRole: "admin" }, diff --git a/web/src/pages/Gitea.tsx b/web/src/pages/Gitea.tsx new file mode 100644 index 0000000..6465cf9 --- /dev/null +++ b/web/src/pages/Gitea.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CurrentUser, type GiteaRepo, type IntegrationSummary } from "../api/client"; + +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}; + } +} + +export default function Gitea({ user }: { user: CurrentUser }) { + const canEdit = user.role === "admin" || user.role === "operator"; + + const [integrations, setIntegrations] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [repos, setRepos] = useState(null); + const [loading, setLoading] = useState(false); + const [rerunningRun, setRerunningRun] = useState(null); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const giteaIntegrations = res.integrations.filter((i) => i.type === "gitea"); + setIntegrations(giteaIntegrations); + if (!selectedId && giteaIntegrations.length > 0) setSelectedId(giteaIntegrations[0].id); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadRepos(id: number) { + setLoading(true); + setError(null); + api.integrations.gitea + .repos(id) + .then((res) => setRepos(res.repos)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadRepos(selected.id); + } else { + setRepos(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + async function rerunFailed(repo: GiteaRepo) { + if (!selectedId || !repo.latestRun) return; + setRerunningRun(repo.latestRun.id); + setError(null); + try { + await api.integrations.gitea.rerunFailed(selectedId, repo.owner, repo.name, repo.latestRun.id); + loadRepos(selectedId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRerunningRun(null); + } + } + + return ( + <> +

Gitea

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No Gitea integration configured yet. Add one under Integrations. +
+
+ ) : ( + <> + {integrations && integrations.length > 1 && ( +
+ +
+ )} + + {selected && !selected.enabled ? ( +
+
+ "{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its + repositories. +
+
+ ) : ( +
+
+

Repositories

+
+ +
+
+
+ + + + + + + + {canEdit && } + + + + {repos?.map((r) => ( + + + + + + {canEdit && ( + + )} + + ))} + {repos?.length === 0 && ( + + + + )} + +
RepositoryLast runBranchStatusActions
+ + {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. +
+
+
+ )} + + )} + + ); +} diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 507970e..2f81a75 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -3,16 +3,11 @@ import { Link } from "react-router-dom"; import { api, type CurrentUser, - 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"; @@ -55,56 +50,6 @@ function formatUptime(seconds: number | null): string { 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 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", @@ -135,20 +80,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { 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 [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); @@ -172,42 +103,7 @@ export default function Integrations({ user }: { user: CurrentUser }) { 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)); - } - 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 === "semaphore" && selected.enabled && !managing) { - loadSemaphoreTemplates(selected.id); - } else { - setSemaphoreData(null); - } if (selected?.type === "proxmox" && selected.enabled && !managing) { loadProxmoxGuests(selected.id); } else { @@ -231,16 +127,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { .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); @@ -271,56 +157,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { } } - 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 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.`)) @@ -450,218 +286,20 @@ export default function Integrations({ user }: { user: CurrentUser }) { {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 && ( - - - - )} - -
DeviceAddressOSStatusLast 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. -
+
+ Manage this integration's devices on the Tailscale page.
) : selected?.type === "gitea" ? (
-
-

Repositories

-
- -
-
- {giteaError &&
{giteaError}
} -
- - - - - - - - {canEdit && } - - - - {giteaRepos?.map((r) => ( - - - - - - {canEdit && ( - - )} - - ))} - {giteaRepos?.length === 0 && ( - - - - )} - -
RepositoryLast runBranchStatusActions
- - {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. -
+
+ Manage this integration's repositories on the Gitea page.
) : 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 && ( - - - - )} - -
TemplateProjectLast runStatusActions
{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. -
+
+ Manage this integration's templates on the Semaphore page.
) : selected?.type === "proxmox" ? ( diff --git a/web/src/pages/Semaphore.tsx b/web/src/pages/Semaphore.tsx new file mode 100644 index 0000000..b3a1392 --- /dev/null +++ b/web/src/pages/Semaphore.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type SemaphoreTemplate, + type SemaphoreTemplatesResponse, +} from "../api/client"; +import { formatDateTime } from "../utils/date"; + +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}; + } +} + +export default function Semaphore({ user }: { user: CurrentUser }) { + const canEdit = user.role === "admin" || user.role === "operator"; + + const [integrations, setIntegrations] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [runningTemplate, setRunningTemplate] = useState(null); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const semaphoreIntegrations = res.integrations.filter((i) => i.type === "semaphore"); + setIntegrations(semaphoreIntegrations); + if (!selectedId && semaphoreIntegrations.length > 0) setSelectedId(semaphoreIntegrations[0].id); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadTemplates(id: number) { + setLoading(true); + setError(null); + api.integrations.semaphore + .templates(id) + .then((res) => setData(res)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadTemplates(selected.id); + } else { + setData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + async function runTemplate(t: SemaphoreTemplate) { + if (!selectedId) return; + if (!confirm(`Run "${t.name}" now?`)) return; + setRunningTemplate(t.id); + setError(null); + try { + await api.integrations.semaphore.run(selectedId, t.projectId, t.id); + loadTemplates(selectedId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRunningTemplate(null); + } + } + + return ( + <> +

Semaphore

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No Semaphore integration configured yet.{" "} + Add one under Integrations. +
+
+ ) : ( + <> + {integrations && integrations.length > 1 && ( +
+ +
+ )} + + {selected && !selected.enabled ? ( +
+
+ "{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its + templates. +
+
+ ) : ( +
+
+

+ Templates + {data && data.summary.failing > 0 && ( + {data.summary.failing} failing + )} +

+
+ +
+
+
+ + + + + + + + {canEdit && } + + + + {data?.templates.map((t) => ( + + + + + + {canEdit && ( + + )} + + ))} + {data?.templates.length === 0 && ( + + + + )} + +
TemplateProjectLast runStatusActions
{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. +
+
+
+ )} + + )} + + ); +} diff --git a/web/src/pages/Tailscale.tsx b/web/src/pages/Tailscale.tsx new file mode 100644 index 0000000..bc6ea75 --- /dev/null +++ b/web/src/pages/Tailscale.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type TailscaleDevice, + type TailscaleDevicesResponse, +} from "../api/client"; +import { formatDateTime } from "../utils/date"; + +export default function Tailscale({ user }: { user: CurrentUser }) { + const canEdit = user.role === "admin" || user.role === "operator"; + + const [integrations, setIntegrations] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const tailscaleIntegrations = res.integrations.filter((i) => i.type === "tailscale"); + setIntegrations(tailscaleIntegrations); + if (!selectedId && tailscaleIntegrations.length > 0) setSelectedId(tailscaleIntegrations[0].id); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadDevices(id: number) { + setLoading(true); + setError(null); + api.integrations.tailscale + .devices(id) + .then((res) => setData(res)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadDevices(selected.id); + } else { + setData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + async function setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) { + if (!selectedId) return; + try { + await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized); + loadDevices(selectedId); + } catch (err) { + setError(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); + loadDevices(selectedId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + return ( + <> +

Tailscale

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No Tailscale integration configured yet.{" "} + Add one under Integrations. +
+
+ ) : ( + <> + {integrations && integrations.length > 1 && ( +
+ +
+ )} + + {selected && !selected.enabled ? ( +
+
+ "{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its + devices. +
+
+ ) : ( +
+
+

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

+
+ +
+
+
+ + + + + + + + + {canEdit && } + + + + {data?.devices.map((d) => ( + + + + + + + {canEdit && ( + + )} + + ))} + {data?.devices.length === 0 && ( + + + + )} + +
DeviceAddressOSStatusLast 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. +
+
+
+ )} + + )} + + ); +}