Applies the useSortable/SortableTh infrastructure introduced in the Diagnostic Log commit to the rest of the app's tables: Audit Log, Users, Servers (manage table), server task tables (grouped by schedule type, on both the all-servers and per-server views), DNS providers/zones/records, Docker containers, Gitea repos, Integrations (manage table), IP Addresses, Proxmox guests, Secrets (already had CSV, gained sorting), Semaphore templates, Synology volumes/disks, and Tailscale devices. Every column header is now click-to-sort (again on the raw field, not its formatted display -- a byte count sorts numerically even though the cell shows "1.2 GB", a date sorts chronologically even though the cell shows "5d 15h 2m"), and every table got an "Export CSV" button next to its Refresh button, exporting whatever's currently sorted/filtered via the existing downloadCsv util. Grouped tables (ServerTaskTable renders one sub-table per schedule type) can't call the useSortable hook per group without breaking the Rules of Hooks, so extracted its comparison core as a standalone sortItems() function, driven by one shared sort-state pair at the component's top level and applied per group. Deliberately left two small (1-6 row) tables embedded inside stat cards unsorted -- Proxmox's per-node storage list and ServerDetail's agent-reported disk list -- since they're secondary detail inside an already-scannable card, not primary list content; happy to add if useful in practice. Verified the shared sort core (sortItems) directly: numeric-aware string compare (so "item2" sorts before "item10"), numbers, booleans, and that null/undefined always sort to the end regardless of direction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
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 <span className="text-secondary">Never</span>;
|
|
}
|
|
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))} <span className={`badge ${style} ms-1`}>{daysLeft < 0 ? "Expired" : `${Math.floor(daysLeft)}d left`}</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function Tailscale({ user }: { user: CurrentUser }) {
|
|
const canEdit = user.role === "admin" || user.role === "operator";
|
|
|
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [data, setData] = useState<TailscaleDevicesResponse | null>(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 (
|
|
<>
|
|
<h2 className="page-title mb-3">Tailscale</h2>
|
|
{error && <div className="alert alert-danger">{error}</div>}
|
|
|
|
{integrations?.length === 0 ? (
|
|
<div className="card">
|
|
<div className="card-body text-secondary">
|
|
No Tailscale integration configured yet.{" "}
|
|
<Link to="/integrations">Add one under Integrations</Link>.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{integrations && integrations.length > 1 && (
|
|
<div className="mb-3" style={{ maxWidth: 320 }}>
|
|
<select
|
|
className="form-select"
|
|
value={selectedId ?? ""}
|
|
onChange={(e) => setSelectedId(Number(e.target.value))}
|
|
>
|
|
{integrations.map((i) => (
|
|
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
|
{i.name}
|
|
{!i.enabled ? " — disabled" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{selected && !selected.enabled ? (
|
|
<div className="card">
|
|
<div className="card-body text-secondary">
|
|
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
|
|
devices.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
Devices
|
|
{data && (
|
|
<span className="text-secondary fw-normal ms-2">
|
|
{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`}
|
|
</span>
|
|
)}
|
|
</h3>
|
|
<div className="card-actions">
|
|
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
|
|
Export CSV
|
|
</button>
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => selected && loadDevices(selected.id)}
|
|
disabled={loading}
|
|
>
|
|
{loading ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-responsive">
|
|
<table className="table table-vcenter card-table">
|
|
<thead>
|
|
<tr>
|
|
<SortableTh<TailscaleDevice> label="Device" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<SortableTh<TailscaleDevice>
|
|
label="Address"
|
|
sortKeyName="primaryAddress"
|
|
activeKey={sortKey}
|
|
direction={sortDir}
|
|
onSort={requestSort}
|
|
/>
|
|
<SortableTh<TailscaleDevice> label="OS" sortKeyName="os" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<SortableTh<TailscaleDevice> label="Status" sortKeyName="online" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<SortableTh<TailscaleDevice>
|
|
label="Last seen"
|
|
sortKeyName="lastSeen"
|
|
activeKey={sortKey}
|
|
direction={sortDir}
|
|
onSort={requestSort}
|
|
/>
|
|
<SortableTh<TailscaleDevice>
|
|
label="Key expiry"
|
|
sortKeyName="keyExpiry"
|
|
activeKey={sortKey}
|
|
direction={sortDir}
|
|
onSort={requestSort}
|
|
/>
|
|
{canEdit && <th className="w-1">Actions</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted?.map((d) => (
|
|
<tr key={d.id}>
|
|
<td>
|
|
{d.label || d.hostname}
|
|
{d.isExitNode && <span className="badge bg-purple-lt ms-2">Exit node</span>}
|
|
</td>
|
|
<td className="text-secondary">{d.primaryAddress}</td>
|
|
<td>{d.os || "—"}</td>
|
|
<td>
|
|
{d.online ? (
|
|
<span className="badge bg-green-lt text-green me-1">Online</span>
|
|
) : (
|
|
<span className="badge bg-secondary-lt text-secondary me-1">Offline</span>
|
|
)}
|
|
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
|
|
</td>
|
|
<td className="text-secondary">{d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"}</td>
|
|
<td>{keyExpiryBadge(d)}</td>
|
|
{canEdit && (
|
|
<td>
|
|
<div className="btn-list flex-nowrap">
|
|
{d.authorized ? (
|
|
<button className="btn btn-sm" onClick={() => setDeviceAuthorized(d, false)}>
|
|
Deauthorize
|
|
</button>
|
|
) : (
|
|
<button className="btn btn-sm btn-primary" onClick={() => setDeviceAuthorized(d, true)}>
|
|
Authorize
|
|
</button>
|
|
)}
|
|
<button className="btn btn-sm btn-outline-danger" onClick={() => removeDevice(d)}>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
{sorted?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={canEdit ? 7 : 6} className="text-secondary text-center">
|
|
No devices in this tailnet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|