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

+69 -6
View File
@@ -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<string, string> = {
cron: "Cron jobs",
@@ -52,6 +56,18 @@ export default function ServerTaskTable({
onEdit: (task: TaskRecord) => void;
onDelete: (task: TaskRecord) => void;
}) {
const [sortKey, setSortKey] = useState<keyof TaskRecord | undefined>(undefined);
const [sortDir, setSortDir] = useState<SortDirection>("asc");
function requestSort(key: keyof TaskRecord) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
}
const byType = new Map<string, TaskRecord[]>();
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 && (
<div className="card-body py-2 d-flex justify-content-end">
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv}>
Export CSV
</button>
</div>
)}
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
<div key={scheduleType}>
<div className="card-body py-2 bg-secondary-lt">
@@ -70,16 +109,40 @@ export default function ServerTaskTable({
<table className="table table-vcenter card-table mb-0">
<thead>
<tr>
<th>Name</th>
<th>Command</th>
<th>Schedule</th>
<th>Next run</th>
<th>Status</th>
<SortableTh<TaskRecord> label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<TaskRecord>
label="Command"
sortKeyName="command"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<TaskRecord>
label="Schedule"
sortKeyName="scheduleExpression"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<TaskRecord>
label="Next run"
sortKeyName="nextRunAt"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<TaskRecord>
label="Status"
sortKeyName="enabled"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
{canEditTasks && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{groupTasks.map((task) => {
{sortItems(groupTasks, sortKey, sortDir).map((task) => {
const nextRun = computeNextRun(task);
return (
<tr key={task.id}>