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

+27 -8
View File
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { api, type AuditLogEntry } from "../api/client";
import { formatDateTime } from "../utils/date";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv";
function targetLabel(e: AuditLogEntry): string {
if (!e.targetType) return "—";
@@ -29,24 +32,40 @@ export default function AuditLog() {
.catch((err) => setError(String(err)));
}, []);
const { sorted, sortKey, sortDir, requestSort } = useSortable(entries, "createdAt", "desc");
function exportCsv() {
if (!sorted) return;
downloadCsv(
"audit-log.csv",
["When", "Actor", "Category", "Action", "Target"],
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e)]),
);
}
return (
<>
<h2 className="page-title mb-3">Audit Log</h2>
<div className="d-flex align-items-center mb-3">
<h2 className="page-title mb-0">Audit Log</h2>
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
Export CSV
</button>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Category</th>
<th>Action</th>
<th>Target</th>
<SortableTh<AuditLogEntry> label="When" sortKeyName="createdAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Actor" sortKeyName="actorLabel" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
</tr>
</thead>
<tbody>
{entries?.map((e) => (
{sorted?.map((e) => (
<tr key={e.id}>
<td>{formatDateTime(new Date(e.createdAt))}</td>
<td>{e.actorLabel ?? "—"}</td>
@@ -57,7 +76,7 @@ export default function AuditLog() {
<td>{targetLabel(e)}</td>
</tr>
))}
{entries?.length === 0 && (
{sorted?.length === 0 && (
<tr>
<td colSpan={5} className="text-secondary text-center">
No activity recorded yet.