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"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; const KEY_EXPIRY_WARN_DAYS = 30; function keyExpiryBadge(device: TailscaleDevice) { if (device.keyExpiryDisabled || !device.keyExpiry) { return Never; } const daysLeft = (new Date(device.keyExpiry).getTime() - Date.now()) / 86_400_000; const style = daysLeft < 0 ? "bg-red-lt text-red" : daysLeft <= KEY_EXPIRY_WARN_DAYS ? "bg-yellow-lt text-yellow" : "bg-green-lt text-green"; return ( <> {formatDateTime(new Date(device.keyExpiry))} {daysLeft < 0 ? "Expired" : `${Math.floor(daysLeft)}d left`} ); } 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)); } } const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.devices); function exportCsv() { if (!sorted) return; downloadCsv( "tailscale-devices.csv", ["Device", "Address", "OS", "Online", "Authorized", "Last seen", "Key expiry"], sorted.map((d) => [ d.label || d.hostname, d.primaryAddress, d.os, d.online ? "yes" : "no", d.authorized ? "yes" : "no", d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "", d.keyExpiryDisabled ? "never" : d.keyExpiry ? formatDateTime(new Date(d.keyExpiry)) : "", ]), ); } 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`} {data.summary.expiringSoon > 0 && `, ${data.summary.expiringSoon} key(s) expiring soon`} )}

label="Device" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Address" sortKeyName="primaryAddress" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="OS" sortKeyName="os" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Status" sortKeyName="online" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Last seen" sortKeyName="lastSeen" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Key expiry" sortKeyName="keyExpiry" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> {canEdit && } {sorted?.map((d) => ( {canEdit && ( )} ))} {sorted?.length === 0 && ( )}
Actions
{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)) : "—"} {keyExpiryBadge(d)}
{d.authorized ? ( ) : ( )}
No devices in this tailnet.
)} )} ); }