Make every table sortable and exportable to CSV
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>
This commit is contained in:
1 parent
e35da87886
commit
f4274c1ad8
16 files changed
+651
-119
No files matched your search
@@ -8,6 +8,9 @@ import {
|
||||
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;
|
||||
|
||||
@@ -89,6 +92,25 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -141,6 +163,9 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
)}
|
||||
</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)}
|
||||
@@ -154,17 +179,35 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Address</th>
|
||||
<th>OS</th>
|
||||
<th>Status</th>
|
||||
<th>Last seen</th>
|
||||
<th>Key expiry</th>
|
||||
<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>
|
||||
{data?.devices.map((d) => (
|
||||
{sorted?.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>
|
||||
{d.label || d.hostname}
|
||||
@@ -202,7 +245,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{data?.devices.length === 0 && (
|
||||
{sorted?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 7 : 6} className="text-secondary text-center">
|
||||
No devices in this tailnet.
|
||||
|
||||
Reference in new issue
Block a user