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. +
+
+
+ )} + + )} + + ); +}