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
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user