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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-18 23:51:14 +02:00
1 parent 81e55fc792
commit 7bf9b03839
13 files changed
+121 -12

No files matched your search

+26
View File
@@ -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 (
<div className="card-footer d-flex align-items-center justify-content-between">
<button className="btn btn-outline-secondary btn-sm" onClick={() => onPageChange(page - 1)} disabled={page === 0}>
← Prev
</button>
<span className="text-secondary small">
Page {page + 1} of {pageCount} ({totalCount} total)
</span>
<button className="btn btn-outline-secondary btn-sm" onClick={() => onPageChange(page + 1)} disabled={page >= pageCount - 1}>
Next →
</button>
</div>
);
}
+28
View File
@@ -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<T>(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 };
}
+5 -1
View File
@@ -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() {
</tr>
</thead>
<tbody>
{sorted?.map((e) => (
{pageItems?.map((e) => (
<tr key={e.id}>
<td>{formatDateTime(new Date(e.createdAt))}</td>
<td>{e.actorLabel ?? "—"}</td>
@@ -86,6 +89,7 @@ export default function AuditLog() {
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
</>
);
+15 -2
View File
@@ -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<DnsProviderType, string> = {
@@ -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 }) {
</tr>
</thead>
<tbody>
{sortedZones?.map((z) => (
{zonePageItems?.map((z) => (
<tr
key={z.id}
role="button"
@@ -414,6 +425,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={zonePage} pageCount={zonePageCount} totalCount={zoneTotalCount} onPageChange={setZonePage} />
</div>
</div>
@@ -542,7 +554,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
</tr>
</thead>
<tbody>
{sortedRecords?.map((r) => (
{recordPageItems?.map((r) => (
<tr key={r.id}>
<td>
<span className="badge bg-blue-lt">{r.type}</span>
@@ -575,6 +587,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={recordPage} pageCount={recordPageCount} totalCount={recordTotalCount} onPageChange={setRecordPage} />
</div>
) : (
<div className="card">
+5 -1
View File
@@ -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 }) {
</tr>
</thead>
<tbody>
{sorted?.map((c) => (
{pageItems?.map((c) => (
<tr key={`${c.environmentId}:${c.id}`}>
<td>{c.name}</td>
<td className="text-secondary">{c.environmentName}</td>
@@ -300,6 +303,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
)}
</>
+5 -1
View File
@@ -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 }) {
</tr>
</thead>
<tbody>
{sorted?.map((r) => (
{pageItems?.map((r) => (
<tr key={r.fullName}>
<td>
<a href={r.htmlUrl} target="_blank" rel="noopener noreferrer">
@@ -217,6 +220,7 @@ export default function Gitea({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
)}
</>
+5 -1
View File
@@ -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 }) {
</tr>
</thead>
<tbody>
{(sorted ?? []).map((entry) => (
{(pageItems ?? []).map((entry) => (
<tr key={entry.id}>
<td>
<span className={`badge ${isIpv6(entry.ipAddress) ? "bg-purple-lt" : "bg-blue-lt"} me-2`}>
@@ -289,6 +292,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
</>
);
+6 -1
View File
@@ -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 }) {
</tr>
</thead>
<tbody>
{sortedGuests?.map((g) => (
{guestPageItems?.map((g) => (
<tr key={`${g.node}:${g.type}:${g.vmid}`}>
<td>
{g.name} <span className="text-secondary">#{g.vmid}</span>
@@ -391,6 +395,7 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={guestPage} pageCount={guestPageCount} totalCount={guestTotalCount} onPageChange={setGuestPage} />
</div>
</>
)}
+5 -1
View File
@@ -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<SecretRecord["type"], string> = {
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 }) {
</tr>
</thead>
<tbody>
{(sorted ?? []).map((s) => (
{(pageItems ?? []).map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>{TYPE_LABELS[s.type]}</td>
@@ -299,6 +302,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
</>
);
+5 -1
View File
@@ -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 }) {
</tr>
</thead>
<tbody>
{sorted?.map((t) => (
{pageItems?.map((t) => (
<tr key={`${t.projectId}:${t.id}`}>
<td>{t.name}</td>
<td className="text-secondary">{t.projectName}</td>
@@ -223,6 +226,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
)}
</>
+6 -1
View File
@@ -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<string, string>, 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 }) {
</tr>
</thead>
<tbody>
{sortedServers?.map((s) => (
{serverPageItems?.map((s) => (
<tr key={s.id}>
<td>
<Link to={`/servers/${s.id}`}>{s.name}</Link>
@@ -284,6 +288,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={serverPage} pageCount={serverPageCount} totalCount={serverTotalCount} onPageChange={setServerPage} />
</div>
</>
) : servers.length === 0 ? (
+5 -1
View File
@@ -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 }) {
</tr>
</thead>
<tbody>
{sorted?.map((d) => (
{pageItems?.map((d) => (
<tr key={d.id}>
<td>
{d.label || d.hostname}
@@ -255,6 +258,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
)}
</>