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 (
+
+ onPageChange(page - 1)} disabled={page === 0}>
+ ← Prev
+
+
+ Page {page + 1} of {pageCount} ({totalCount} total)
+
+ onPageChange(page + 1)} disabled={page >= pageCount - 1}>
+ Next →
+
+
+ );
+}
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 }) {
+
)}
>