From f4274c1ad85f6c241dea0fea53486e5ad886f093 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Thu, 17 Sep 2026 21:54:36 +0200 Subject: [PATCH] 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 --- README.md | 4 + web/src/components/ServerTaskTable.tsx | 75 ++++++++++++-- web/src/hooks/useSortable.ts | 36 ++++--- web/src/pages/AuditLog.tsx | 35 +++++-- web/src/pages/Dns.tsx | 129 ++++++++++++++++++++++--- web/src/pages/Docker.tsx | 35 +++++-- web/src/pages/Gitea.tsx | 29 +++++- web/src/pages/Integrations.tsx | 35 ++++++- web/src/pages/Ipam.tsx | 18 ++-- web/src/pages/Proxmox.tsx | 47 +++++++-- web/src/pages/Secrets.tsx | 40 ++++++-- web/src/pages/Semaphore.tsx | 36 ++++++- web/src/pages/Servers.tsx | 41 +++++++- web/src/pages/Synology.tsx | 112 ++++++++++++++++++--- web/src/pages/Tailscale.tsx | 59 +++++++++-- web/src/pages/Users.tsx | 39 ++++++-- 16 files changed, 651 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 3227889..ba3784a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ All modules from the original plan are built: Add/edit/enable/disable/remove each integration's credential itself under Integrations → Manage integrations. +- Every table in the app is click-to-sort on any column (numbers, booleans, + and dates/text sort correctly regardless of how the column formats them) + and has an "Export CSV" button next to it that exports whatever's + currently sorted/filtered. - **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP, generic webhook) with per-channel test buttons, per-event toggles (DNS record added/updated/deleted, daily secret-expiry reminder and daily diff --git a/web/src/components/ServerTaskTable.tsx b/web/src/components/ServerTaskTable.tsx index cd31f59..b909266 100644 --- a/web/src/components/ServerTaskTable.tsx +++ b/web/src/components/ServerTaskTable.tsx @@ -1,7 +1,11 @@ +import { useState } from "react"; import cronstrue from "cronstrue"; import { CronExpressionParser } from "cron-parser"; import type { TaskRecord } from "../api/client"; import { formatDateTime, is24HourFormat } from "../utils/date"; +import { sortItems, type SortDirection } from "../hooks/useSortable"; +import SortableTh from "./SortableTh"; +import { downloadCsv } from "../utils/csv"; export const SCHEDULE_TYPE_LABELS: Record = { cron: "Cron jobs", @@ -52,6 +56,18 @@ export default function ServerTaskTable({ onEdit: (task: TaskRecord) => void; onDelete: (task: TaskRecord) => void; }) { + const [sortKey, setSortKey] = useState(undefined); + const [sortDir, setSortDir] = useState("asc"); + + function requestSort(key: keyof TaskRecord) { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("asc"); + } + } + const byType = new Map(); for (const task of tasks) { const list = byType.get(task.scheduleType) ?? []; @@ -59,8 +75,31 @@ export default function ServerTaskTable({ byType.set(task.scheduleType, list); } + function exportCsv() { + downloadCsv( + "tasks.csv", + ["Schedule type", "Name", "Command", "Schedule", "Enabled", "Missing", "Origin"], + tasks.map((t) => [ + SCHEDULE_TYPE_LABELS[t.scheduleType] ?? t.scheduleType, + t.name, + t.command ?? "", + t.scheduleExpression ?? "", + t.enabled ? "yes" : "no", + t.isStale ? "yes" : "no", + t.origin, + ]), + ); + } + return ( <> + {tasks.length > 0 && ( +
+ +
+ )} {[...byType.entries()].map(([scheduleType, groupTasks]) => (
@@ -70,16 +109,40 @@ export default function ServerTaskTable({ - - - - - + label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + label="Command" + sortKeyName="command" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + label="Schedule" + sortKeyName="scheduleExpression" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + label="Next run" + sortKeyName="nextRunAt" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + label="Status" + sortKeyName="enabled" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> {canEditTasks && } - {groupTasks.map((task) => { + {sortItems(groupTasks, sortKey, sortDir).map((task) => { const nextRun = computeNextRun(task); return ( diff --git a/web/src/hooks/useSortable.ts b/web/src/hooks/useSortable.ts index f2bf9bc..22b0abd 100644 --- a/web/src/hooks/useSortable.ts +++ b/web/src/hooks/useSortable.ts @@ -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(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(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 }; diff --git a/web/src/pages/AuditLog.tsx b/web/src/pages/AuditLog.tsx index cb4d015..61e9e5b 100644 --- a/web/src/pages/AuditLog.tsx +++ b/web/src/pages/AuditLog.tsx @@ -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 ( <> -

Audit Log

+
+

Audit Log

+ +
{error &&
{error}
}
NameCommandScheduleNext runStatusActions
- - - - - + label="When" sortKeyName="createdAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Actor" sortKeyName="actorLabel" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> - {entries?.map((e) => ( + {sorted?.map((e) => ( @@ -57,7 +76,7 @@ export default function AuditLog() { ))} - {entries?.length === 0 && ( + {sorted?.length === 0 && (
WhenActorCategoryActionTarget
{formatDateTime(new Date(e.createdAt))} {e.actorLabel ?? "—"}{targetLabel(e)}
No activity recorded yet. diff --git a/web/src/pages/Dns.tsx b/web/src/pages/Dns.tsx index c07fd35..e6bab2c 100644 --- a/web/src/pages/Dns.tsx +++ b/web/src/pages/Dns.tsx @@ -10,6 +10,9 @@ import { } from "../api/client"; import DnsProviderForm from "../components/DnsProviderForm"; import { formatDateTime } from "../utils/date"; +import { useSortable } from "../hooks/useSortable"; +import SortableTh from "../components/SortableTh"; +import { downloadCsv } from "../utils/csv"; const PROVIDER_LABELS: Record = { cloudflare: "Cloudflare", @@ -186,6 +189,39 @@ export default function Dns({ user }: { user: CurrentUser }) { const selectedProvider = providers?.find((p) => p.id === selectedProviderId) ?? null; + const { sorted: sortedProviders, sortKey: providerSortKey, sortDir: providerSortDir, requestSort: requestProviderSort } = useSortable( + providers, + ); + const { sorted: sortedZones, sortKey: zoneSortKey, sortDir: zoneSortDir, requestSort: requestZoneSort } = useSortable(zones); + const { sorted: sortedRecords, sortKey: recordSortKey, sortDir: recordSortDir, requestSort: requestRecordSort } = useSortable(records); + + function exportProvidersCsv() { + if (!sortedProviders) return; + downloadCsv( + "dns-providers.csv", + ["Name", "Provider", "Status"], + sortedProviders.map((p) => [p.name, PROVIDER_LABELS[p.providerType], p.enabled ? "Enabled" : "Disabled"]), + ); + } + + function exportZonesCsv() { + if (!sortedZones) return; + downloadCsv( + "dns-zones.csv", + ["Zone", "Records", "Synced"], + sortedZones.map((z) => [z.name, z.recordCount, z.syncedAt ? formatDateTime(new Date(z.syncedAt)) : "never"]), + ); + } + + function exportRecordsCsv() { + if (!sortedRecords) return; + downloadCsv( + `dns-records${selectedZone ? `-${selectedZone.name}` : ""}.csv`, + ["Type", "Name", "Content", "TTL"], + sortedRecords.map((r) => [r.type, r.name, r.content, r.ttl ?? ""]), + ); + } + return ( <>
@@ -210,18 +246,45 @@ export default function Dns({ user }: { user: CurrentUser }) { onCancel={() => setManagingProviders(false)} />
+
+ +
- - - + + label="Name" + sortKeyName="name" + activeKey={providerSortKey} + direction={providerSortDir} + onSort={requestProviderSort} + /> + + label="Provider" + sortKeyName="providerType" + activeKey={providerSortKey} + direction={providerSortDir} + onSort={requestProviderSort} + /> + + label="Status" + sortKeyName="enabled" + activeKey={providerSortKey} + direction={providerSortDir} + onSort={requestProviderSort} + /> - {providers?.map((p) => ( + {sortedProviders?.map((p) => ( ))} - {providers?.length === 0 && ( + {sortedProviders?.length === 0 && (
NameProviderStatusActions
{p.name} @@ -244,7 +307,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
No DNS providers configured yet. @@ -294,19 +357,40 @@ export default function Dns({ user }: { user: CurrentUser }) {

Zones

+
+ +
{zonesError &&
{zonesError}
}
- - - + label="Zone" sortKeyName="name" activeKey={zoneSortKey} direction={zoneSortDir} onSort={requestZoneSort} /> + + label="Records" + sortKeyName="recordCount" + activeKey={zoneSortKey} + direction={zoneSortDir} + onSort={requestZoneSort} + /> + + label="Synced" + sortKeyName="syncedAt" + activeKey={zoneSortKey} + direction={zoneSortDir} + onSort={requestZoneSort} + /> - {zones?.map((z) => ( + {sortedZones?.map((z) => ( ))} - {zones && zones.length === 0 && ( + {sortedZones && sortedZones.length === 0 && (
ZoneRecordsSynced
No zones found for this provider. @@ -339,6 +423,13 @@ export default function Dns({ user }: { user: CurrentUser }) {

{selectedZone.name}

+ {canEdit && (