From 7bf9b038391967a6212ff08f7d3a254c942d24f1 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Fri, 18 Sep 2026 23:51:14 +0200 Subject: [PATCH] Paginate tables that can grow large Sorting/CSV export were already app-wide; tables with a real chance of growing into dozens or hundreds of rows (a busy tailnet, a big DNS zone, a homelab's full IP inventory, a Gitea org with many repos, ...) had no pagination at all, making them a long unbroken scroll. New usePagination hook (client-side slicing over an already-sorted/ filtered array, 20 rows per page) and a matching Pagination component (Prev/Next + "Page X of Y (N total)", hidden entirely when everything fits on one page). The current page is clamped to the valid range on every render rather than reset via an effect, so switching to a smaller data set (a different selected integration, a filter that narrows the result) can never strand the view on a now-nonexistent page -- no per-page "reset on change" wiring needed anywhere. Applied to Audit Log, DNS zones and records, IP Addresses, Secrets, Servers (manage table), Docker containers, Proxmox guests, Semaphore templates, Gitea repos, and Tailscale devices. CSV export keeps exporting the full sorted/filtered array regardless of which page is currently shown -- pagination only affects what's rendered on screen. Left the already-small tables (Synology volumes/disks, Users, Integrations, per-node Proxmox storage) unpaginated, and left the Diagnostic Log's existing server-driven pagination as-is rather than bolting a second, different pagination scheme onto it. Verified the clamping logic directly: a normal page, the trailing partial page, a requested page beyond the end (clamps to the last valid page instead of rendering empty), and an empty result set (clamps to page 0 with a page count of 1 instead of a negative range). Co-Authored-By: Claude Sonnet 5 --- README.md | 6 +++++- web/src/components/Pagination.tsx | 26 ++++++++++++++++++++++++++ web/src/hooks/usePagination.ts | 28 ++++++++++++++++++++++++++++ web/src/pages/AuditLog.tsx | 6 +++++- web/src/pages/Dns.tsx | 17 +++++++++++++++-- web/src/pages/Docker.tsx | 6 +++++- web/src/pages/Gitea.tsx | 6 +++++- web/src/pages/Ipam.tsx | 6 +++++- web/src/pages/Proxmox.tsx | 7 ++++++- web/src/pages/Secrets.tsx | 6 +++++- web/src/pages/Semaphore.tsx | 6 +++++- web/src/pages/Servers.tsx | 7 ++++++- web/src/pages/Tailscale.tsx | 6 +++++- 13 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 web/src/components/Pagination.tsx create mode 100644 web/src/hooks/usePagination.ts diff --git a/README.md b/README.md index e2378b5..98c0032 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,11 @@ All modules from the original plan are built: - 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. + currently sorted/filtered. Tables that can realistically grow large + (DNS zones/records, IP Addresses, Secrets, Servers, Audit Log, and each + integration's device/container/guest/repo/template list) are paginated + 20 rows at a time — CSV export still covers every sorted/filtered row, + not just the current page. - **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/Pagination.tsx b/web/src/components/Pagination.tsx new file mode 100644 index 0000000..2e61f86 --- /dev/null +++ b/web/src/components/Pagination.tsx @@ -0,0 +1,26 @@ +export default function Pagination({ + page, + pageCount, + totalCount, + onPageChange, +}: { + page: number; + pageCount: number; + totalCount: number; + onPageChange: (page: number) => void; +}) { + if (pageCount <= 1) return null; + return ( +
+ + + Page {page + 1} of {pageCount} ({totalCount} total) + + +
+ ); +} diff --git a/web/src/hooks/usePagination.ts b/web/src/hooks/usePagination.ts new file mode 100644 index 0000000..bf74b9a --- /dev/null +++ b/web/src/hooks/usePagination.ts @@ -0,0 +1,28 @@ +import { useMemo, useState } from "react"; + +/** + * Client-side pagination over an already-fetched array. Purely a display + * concern — CSV export and similar should keep using the full (sorted/ + * filtered) array, not pageItems. + * + * The current page is clamped to the valid range on every render rather + * than reset via an effect, so switching data sets (a different selected + * integration, a filter that shrinks the result set) can never strand the + * view on a page that no longer exists — no caller-side "reset page on + * filter change" wiring required. + */ +export function usePagination(items: T[] | null | undefined, pageSize = 20) { + const [page, setPage] = useState(0); + + const totalCount = items?.length ?? 0; + const pageCount = Math.max(1, Math.ceil(totalCount / pageSize)); + const safePage = Math.min(page, pageCount - 1); + + const pageItems = useMemo(() => { + if (!items) return null; + const start = safePage * pageSize; + return items.slice(start, start + pageSize); + }, [items, safePage, pageSize]); + + return { pageItems, page: safePage, setPage, pageCount, totalCount }; +} diff --git a/web/src/pages/AuditLog.tsx b/web/src/pages/AuditLog.tsx index 61e9e5b..a0f6192 100644 --- a/web/src/pages/AuditLog.tsx +++ b/web/src/pages/AuditLog.tsx @@ -3,6 +3,8 @@ import { api, type AuditLogEntry } from "../api/client"; import { formatDateTime } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; function targetLabel(e: AuditLogEntry): string { @@ -33,6 +35,7 @@ export default function AuditLog() { }, []); const { sorted, sortKey, sortDir, requestSort } = useSortable(entries, "createdAt", "desc"); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); function exportCsv() { if (!sorted) return; @@ -65,7 +68,7 @@ export default function AuditLog() { - {sorted?.map((e) => ( + {pageItems?.map((e) => ( {formatDateTime(new Date(e.createdAt))} {e.actorLabel ?? "—"} @@ -86,6 +89,7 @@ export default function AuditLog() { + ); diff --git a/web/src/pages/Dns.tsx b/web/src/pages/Dns.tsx index e6bab2c..cc80f87 100644 --- a/web/src/pages/Dns.tsx +++ b/web/src/pages/Dns.tsx @@ -12,6 +12,8 @@ import DnsProviderForm from "../components/DnsProviderForm"; import { formatDateTime } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; const PROVIDER_LABELS: Record = { @@ -194,6 +196,15 @@ export default function Dns({ user }: { user: CurrentUser }) { ); const { sorted: sortedZones, sortKey: zoneSortKey, sortDir: zoneSortDir, requestSort: requestZoneSort } = useSortable(zones); const { sorted: sortedRecords, sortKey: recordSortKey, sortDir: recordSortDir, requestSort: requestRecordSort } = useSortable(records); + const { pageItems: zonePageItems, page: zonePage, setPage: setZonePage, pageCount: zonePageCount, totalCount: zoneTotalCount } = + usePagination(sortedZones); + const { + pageItems: recordPageItems, + page: recordPage, + setPage: setRecordPage, + pageCount: recordPageCount, + totalCount: recordTotalCount, + } = usePagination(sortedRecords); function exportProvidersCsv() { if (!sortedProviders) return; @@ -390,7 +401,7 @@ export default function Dns({ user }: { user: CurrentUser }) { - {sortedZones?.map((z) => ( + {zonePageItems?.map((z) => ( + @@ -542,7 +554,7 @@ export default function Dns({ user }: { user: CurrentUser }) { - {sortedRecords?.map((r) => ( + {recordPageItems?.map((r) => ( {r.type} @@ -575,6 +587,7 @@ export default function Dns({ user }: { user: CurrentUser }) { + ) : (
diff --git a/web/src/pages/Docker.tsx b/web/src/pages/Docker.tsx index f5283d6..ede1e95 100644 --- a/web/src/pages/Docker.tsx +++ b/web/src/pages/Docker.tsx @@ -9,6 +9,8 @@ import { } from "../api/client"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; function updateBadge(c: DockhandContainer) { @@ -88,6 +90,7 @@ export default function Docker({ user }: { user: CurrentUser }) { const filteredContainers = (data?.containers ?? []).filter((c) => !hostFilter || c.environmentName === hostFilter); const { sorted, sortKey, sortDir, requestSort } = useSortable(filteredContainers); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); function exportCsv() { if (!sorted) return; @@ -246,7 +249,7 @@ export default function Docker({ user }: { user: CurrentUser }) { - {sorted?.map((c) => ( + {pageItems?.map((c) => ( {c.name} {c.environmentName} @@ -300,6 +303,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
+ )} diff --git a/web/src/pages/Gitea.tsx b/web/src/pages/Gitea.tsx index ddd33bc..13e316f 100644 --- a/web/src/pages/Gitea.tsx +++ b/web/src/pages/Gitea.tsx @@ -3,6 +3,8 @@ import { Link } from "react-router-dom"; import { api, type CurrentUser, type GiteaRepo, type IntegrationSummary } from "../api/client"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; function runStatusBadge(repo: GiteaRepo) { @@ -89,6 +91,7 @@ export default function Gitea({ user }: { user: CurrentUser }) { } const { sorted, sortKey, sortDir, requestSort } = useSortable(repos); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); function exportCsv() { if (!sorted) return; @@ -171,7 +174,7 @@ export default function Gitea({ user }: { user: CurrentUser }) { - {sorted?.map((r) => ( + {pageItems?.map((r) => ( @@ -217,6 +220,7 @@ export default function Gitea({ user }: { user: CurrentUser }) { + )} diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index 371dc85..8c2e2f8 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -3,6 +3,8 @@ import { api, type CurrentUser, type IpamEntry, type IpamInput } from "../api/cl import { downloadCsv } from "../utils/csv"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" }; @@ -114,6 +116,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { } const { sorted, sortKey, sortDir, requestSort } = useSortable(filtered); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); function exportCsv() { downloadCsv( @@ -248,7 +251,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { - {(sorted ?? []).map((entry) => ( + {(pageItems ?? []).map((entry) => ( @@ -289,6 +292,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { + ); diff --git a/web/src/pages/Proxmox.tsx b/web/src/pages/Proxmox.tsx index 710898a..78ffe26 100644 --- a/web/src/pages/Proxmox.tsx +++ b/web/src/pages/Proxmox.tsx @@ -10,6 +10,8 @@ import { } from "../api/client"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; function proxmoxStatusBadge(status: string) { @@ -128,6 +130,8 @@ export default function Proxmox({ user }: { user: CurrentUser }) { } const { sorted: sortedGuests, sortKey: guestSortKey, sortDir: guestSortDir, requestSort: requestGuestSort } = useSortable(data?.guests); + const { pageItems: guestPageItems, page: guestPage, setPage: setGuestPage, pageCount: guestPageCount, totalCount: guestTotalCount } = + usePagination(sortedGuests); function exportGuestsCsv() { if (!sortedGuests) return; @@ -329,7 +333,7 @@ export default function Proxmox({ user }: { user: CurrentUser }) { - {sortedGuests?.map((g) => ( + {guestPageItems?.map((g) => ( {g.name} #{g.vmid} @@ -391,6 +395,7 @@ export default function Proxmox({ user }: { user: CurrentUser }) { + )} diff --git a/web/src/pages/Secrets.tsx b/web/src/pages/Secrets.tsx index 1647796..51dcf63 100644 --- a/web/src/pages/Secrets.tsx +++ b/web/src/pages/Secrets.tsx @@ -3,6 +3,8 @@ import { api, type CurrentUser, type SecretInput, type SecretRecord, type Secret import { downloadCsv } from "../utils/csv"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; const TYPE_LABELS: Record = { api_token: "API Token", @@ -103,6 +105,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { } const { sorted, sortKey, sortDir, requestSort } = useSortable(filtered); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); function exportCsv() { downloadCsv( @@ -265,7 +268,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { - {(sorted ?? []).map((s) => ( + {(pageItems ?? []).map((s) => ( {s.name} {TYPE_LABELS[s.type]} @@ -299,6 +302,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { + ); diff --git a/web/src/pages/Semaphore.tsx b/web/src/pages/Semaphore.tsx index 2474a05..7a19ec5 100644 --- a/web/src/pages/Semaphore.tsx +++ b/web/src/pages/Semaphore.tsx @@ -10,6 +10,8 @@ import { import { formatDateTime } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; function semaphoreStatusBadge(template: SemaphoreTemplate) { @@ -97,6 +99,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) { } const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.templates); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); function exportCsv() { if (!sorted) return; @@ -190,7 +193,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) { - {sorted?.map((t) => ( + {pageItems?.map((t) => ( {t.name} {t.projectName} @@ -223,6 +226,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) { + )} diff --git a/web/src/pages/Servers.tsx b/web/src/pages/Servers.tsx index 6e6a4c2..4958e54 100644 --- a/web/src/pages/Servers.tsx +++ b/web/src/pages/Servers.tsx @@ -5,6 +5,8 @@ import CopyButton from "../components/CopyButton"; import { formatDateTime } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; function typeBadgeStyle(colors: Record, type: string): CSSProperties { @@ -96,6 +98,8 @@ export default function Servers({ user }: { user: CurrentUser }) { } const { sorted: sortedServers, sortKey, sortDir, requestSort } = useSortable(servers); + const { pageItems: serverPageItems, page: serverPage, setPage: setServerPage, pageCount: serverPageCount, totalCount: serverTotalCount } = + usePagination(sortedServers); function exportCsv() { if (!sortedServers) return; @@ -251,7 +255,7 @@ export default function Servers({ user }: { user: CurrentUser }) { - {sortedServers?.map((s) => ( + {serverPageItems?.map((s) => ( {s.name} @@ -284,6 +288,7 @@ export default function Servers({ user }: { user: CurrentUser }) { + ) : servers.length === 0 ? ( diff --git a/web/src/pages/Tailscale.tsx b/web/src/pages/Tailscale.tsx index 2a81964..4242270 100644 --- a/web/src/pages/Tailscale.tsx +++ b/web/src/pages/Tailscale.tsx @@ -10,6 +10,8 @@ import { import { formatDateTime } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; +import { usePagination } from "../hooks/usePagination"; +import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; const KEY_EXPIRY_WARN_DAYS = 30; @@ -93,6 +95,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { } const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.devices); + const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted); function exportCsv() { if (!sorted) return; @@ -207,7 +210,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { - {sorted?.map((d) => ( + {pageItems?.map((d) => ( {d.label || d.hostname} @@ -255,6 +258,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { + )}