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:
bobbanandClaude Sonnet 5 committed 2026-09-17 21:54:36 +02:00
1 parent e35da87886
commit f4274c1ad8
16 files changed
+651 -119

No files matched your search

+21 -15
View File
@@ -2,6 +2,25 @@ import { useMemo, useState } from "react";
export type SortDirection = "asc" | "desc";
/** The comparison core of useSortable, exposed standalone for tables that render several independent sub-tables (one sort state driving each via a plain function call, since hooks can't run inside a loop/map). */
export function sortItems<T>(items: T[], sortKey: keyof T | undefined, sortDir: SortDirection): T[] {
if (!sortKey) return items;
const copy = [...items];
copy.sort((a, b) => {
const av = a[sortKey] as unknown;
const bv = b[sortKey] as unknown;
if (av === bv) return 0;
if (av === null || av === undefined) return 1;
if (bv === null || bv === undefined) return -1;
let cmp: number;
if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
else if (typeof av === "boolean" && typeof bv === "boolean") cmp = av === bv ? 0 : av ? 1 : -1;
else cmp = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: "base" });
return sortDir === "asc" ? cmp : -cmp;
});
return copy;
}
/**
* Generic client-side table sort. Sorts on the raw underlying field (numbers
* numerically, booleans false-before-true, everything else via a
@@ -24,21 +43,8 @@ export function useSortable<T>(items: T[] | null | undefined, initialKey?: keyof
}
const sorted = useMemo(() => {
if (!items || !sortKey) return items ?? null;
const copy = [...items];
copy.sort((a, b) => {
const av = a[sortKey] as unknown;
const bv = b[sortKey] as unknown;
if (av === bv) return 0;
if (av === null || av === undefined) return 1;
if (bv === null || bv === undefined) return -1;
let cmp: number;
if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
else if (typeof av === "boolean" && typeof bv === "boolean") cmp = av === bv ? 0 : av ? 1 : -1;
else cmp = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: "base" });
return sortDir === "asc" ? cmp : -cmp;
});
return copy;
if (!items) return null;
return sortItems(items, sortKey, sortDir);
}, [items, sortKey, sortDir]);
return { sorted, sortKey, sortDir, requestSort };