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 }) { + )}