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
+113
-16
@@ -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<DnsProviderType, string> = {
|
||||
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 (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
@@ -210,18 +246,45 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
onCancel={() => setManagingProviders(false)}
|
||||
/>
|
||||
<div className="card">
|
||||
<div className="card-header justify-content-end">
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={exportProvidersCsv}
|
||||
disabled={!providers || providers.length === 0}
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Provider</th>
|
||||
<th>Status</th>
|
||||
<SortableTh<DnsProviderSummary>
|
||||
label="Name"
|
||||
sortKeyName="name"
|
||||
activeKey={providerSortKey}
|
||||
direction={providerSortDir}
|
||||
onSort={requestProviderSort}
|
||||
/>
|
||||
<SortableTh<DnsProviderSummary>
|
||||
label="Provider"
|
||||
sortKeyName="providerType"
|
||||
activeKey={providerSortKey}
|
||||
direction={providerSortDir}
|
||||
onSort={requestProviderSort}
|
||||
/>
|
||||
<SortableTh<DnsProviderSummary>
|
||||
label="Status"
|
||||
sortKeyName="enabled"
|
||||
activeKey={providerSortKey}
|
||||
direction={providerSortDir}
|
||||
onSort={requestProviderSort}
|
||||
/>
|
||||
<th className="w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{providers?.map((p) => (
|
||||
{sortedProviders?.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.name}</td>
|
||||
<td>
|
||||
@@ -244,7 +307,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{providers?.length === 0 && (
|
||||
{sortedProviders?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
No DNS providers configured yet.
|
||||
@@ -294,19 +357,40 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Zones</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={exportZonesCsv}
|
||||
disabled={!sortedZones || sortedZones.length === 0}
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{zonesError && <div className="alert alert-danger m-3 mb-0">{zonesError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th>Records</th>
|
||||
<th>Synced</th>
|
||||
<SortableTh<DnsZone> label="Zone" sortKeyName="name" activeKey={zoneSortKey} direction={zoneSortDir} onSort={requestZoneSort} />
|
||||
<SortableTh<DnsZone>
|
||||
label="Records"
|
||||
sortKeyName="recordCount"
|
||||
activeKey={zoneSortKey}
|
||||
direction={zoneSortDir}
|
||||
onSort={requestZoneSort}
|
||||
/>
|
||||
<SortableTh<DnsZone>
|
||||
label="Synced"
|
||||
sortKeyName="syncedAt"
|
||||
activeKey={zoneSortKey}
|
||||
direction={zoneSortDir}
|
||||
onSort={requestZoneSort}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zones?.map((z) => (
|
||||
{sortedZones?.map((z) => (
|
||||
<tr
|
||||
key={z.id}
|
||||
role="button"
|
||||
@@ -320,7 +404,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{zones && zones.length === 0 && (
|
||||
{sortedZones && sortedZones.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-secondary text-center">
|
||||
No zones found for this provider.
|
||||
@@ -339,6 +423,13 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{selectedZone.name}</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary me-2"
|
||||
onClick={exportRecordsCsv}
|
||||
disabled={!sortedRecords || sortedRecords.length === 0}
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-sm btn-outline-secondary me-2" onClick={syncZone} disabled={syncing}>
|
||||
{syncing ? "Syncing…" : "Sync now"}
|
||||
@@ -437,15 +528,21 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Content</th>
|
||||
<th>TTL</th>
|
||||
<SortableTh<DnsRecord> label="Type" sortKeyName="type" activeKey={recordSortKey} direction={recordSortDir} onSort={requestRecordSort} />
|
||||
<SortableTh<DnsRecord> label="Name" sortKeyName="name" activeKey={recordSortKey} direction={recordSortDir} onSort={requestRecordSort} />
|
||||
<SortableTh<DnsRecord>
|
||||
label="Content"
|
||||
sortKeyName="content"
|
||||
activeKey={recordSortKey}
|
||||
direction={recordSortDir}
|
||||
onSort={requestRecordSort}
|
||||
/>
|
||||
<SortableTh<DnsRecord> label="TTL" sortKeyName="ttl" activeKey={recordSortKey} direction={recordSortDir} onSort={requestRecordSort} />
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records?.map((r) => (
|
||||
{sortedRecords?.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<span className="badge bg-blue-lt">{r.type}</span>
|
||||
@@ -467,7 +564,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{records?.length === 0 && (
|
||||
{sortedRecords?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
|
||||
No cached records.{" "}
|
||||
|
||||
Reference in new issue
Block a user