Files
Homelab-manager/web/src/pages/Dns.tsx
T
bobbanandClaude Sonnet 5 7bf9b03839 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>
2026-09-18 23:51:14 +02:00

606 lines
25 KiB
TypeScript

import { useEffect, useState, type CSSProperties } from "react";
import {
api,
type CurrentUser,
type DnsProviderSummary,
type DnsProviderType,
type DnsRecord,
type DnsRecordInput,
type DnsZone,
} from "../api/client";
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> = {
cloudflare: "Cloudflare",
loopia: "Loopia",
pihole: "Pi-hole",
azure: "Azure DNS",
cpanel: "cPanel",
technitium: "Technitium",
};
const emptyRecordForm: DnsRecordInput = { type: "A", name: "", content: "", ttl: 3600, priority: undefined, proxied: undefined };
function providerBadgeStyle(colors: Record<string, string>, providerType: DnsProviderType): CSSProperties {
const color = colors[providerType];
if (!color) return {};
return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` };
}
export default function Dns({ user }: { user: CurrentUser }) {
const isAdmin = user.role === "admin";
const canEdit = user.role === "admin" || user.role === "operator";
const [providers, setProviders] = useState<DnsProviderSummary[] | null>(null);
const [managingProviders, setManagingProviders] = useState(false);
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
useEffect(() => {
api.settings.providerColors().then((res) => setProviderColors(res.providerColors)).catch(() => {});
}, []);
const [zones, setZones] = useState<DnsZone[] | null>(null);
const [zonesError, setZonesError] = useState<string | null>(null);
const [selectedZone, setSelectedZone] = useState<DnsZone | null>(null);
const [records, setRecords] = useState<DnsRecord[] | null>(null);
const [syncedAt, setSyncedAt] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [recordForm, setRecordForm] = useState<DnsRecordInput | null>(null);
const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
const [savingRecord, setSavingRecord] = useState(false);
function loadProviders() {
api.dns.providers
.list()
.then((res) => {
setProviders(res.providers);
if (!selectedProviderId && res.providers.length > 0) setSelectedProviderId(res.providers[0].id);
})
.catch((err) => setError(String(err)));
}
useEffect(loadProviders, []);
function loadZones(providerId: number) {
setZonesError(null);
setZones(null);
setSelectedZone(null);
setRecords(null);
api.dns.zones
.list(providerId)
.then((res) => setZones(res.zones))
.catch((err) => setZonesError(err instanceof Error ? err.message : String(err)));
}
useEffect(() => {
if (selectedProviderId && !managingProviders) loadZones(selectedProviderId);
}, [selectedProviderId, managingProviders]);
function loadRecords(providerId: number, zone: DnsZone) {
api.dns.records
.list(providerId, zone.id)
.then((res) => {
setRecords(res.records);
setSyncedAt(res.syncedAt);
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}
function selectZone(zone: DnsZone) {
setSelectedZone(zone);
setRecordForm(null);
setEditingRecordId(null);
if (selectedProviderId) loadRecords(selectedProviderId, zone);
}
async function syncZone() {
if (!selectedProviderId || !selectedZone) return;
setSyncing(true);
setError(null);
try {
const res = await api.dns.records.sync(selectedProviderId, selectedZone.id, selectedZone.name);
setRecords(res.records);
setSyncedAt(res.syncedAt);
loadZones(selectedProviderId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSyncing(false);
}
}
function startAddRecord() {
setEditingRecordId(null);
setRecordForm({ ...emptyRecordForm });
}
function startEditRecord(r: DnsRecord) {
setEditingRecordId(r.id);
setRecordForm({
type: r.type,
name: r.name,
content: r.content,
ttl: r.ttl ?? undefined,
priority: r.priority ?? undefined,
proxied: r.proxied ?? undefined,
});
}
async function submitRecord(e: React.FormEvent) {
e.preventDefault();
if (!selectedProviderId || !selectedZone || !recordForm) return;
setSavingRecord(true);
setError(null);
try {
if (editingRecordId) {
await api.dns.records.update(selectedProviderId, selectedZone.id, editingRecordId, recordForm);
} else {
await api.dns.records.create(selectedProviderId, selectedZone.id, recordForm);
}
setRecordForm(null);
setEditingRecordId(null);
loadRecords(selectedProviderId, selectedZone);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSavingRecord(false);
}
}
async function removeRecord(r: DnsRecord) {
if (!selectedProviderId || !selectedZone) return;
if (!confirm(`Delete ${r.type} record "${r.name}"?`)) return;
setError(null);
try {
await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id);
loadRecords(selectedProviderId, selectedZone);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
async function toggleProviderEnabled(p: DnsProviderSummary) {
try {
await api.dns.providers.update(p.id, { enabled: !p.enabled });
loadProviders();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
async function removeProvider(p: DnsProviderSummary) {
if (!confirm(`Delete provider "${p.name}"? This removes its cached zones and records too.`)) return;
try {
await api.dns.providers.remove(p.id);
if (selectedProviderId === p.id) setSelectedProviderId(null);
loadProviders();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
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);
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;
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">
<h2 className="page-title mb-0">DNS</h2>
{isAdmin && (
<button
className="btn btn-outline-secondary ms-auto"
onClick={() => setManagingProviders((v) => !v)}
>
{managingProviders ? "Back to browsing" : "Manage providers"}
</button>
)}
</div>
{error && <div className="alert alert-danger">{error}</div>}
{managingProviders ? (
<>
<DnsProviderForm
onCreated={() => {
loadProviders();
}}
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>
<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>
{sortedProviders?.map((p) => (
<tr key={p.id}>
<td>{p.name}</td>
<td>
<span className="badge" style={providerBadgeStyle(providerColors, p.providerType)}>
{PROVIDER_LABELS[p.providerType]}
</span>
</td>
<td>
<button
className={`badge ${p.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
onClick={() => toggleProviderEnabled(p)}
>
{p.enabled ? "Enabled" : "Disabled"}
</button>
</td>
<td>
<button className="btn btn-sm btn-outline-danger" onClick={() => removeProvider(p)}>
Delete
</button>
</td>
</tr>
))}
{sortedProviders?.length === 0 && (
<tr>
<td colSpan={4} className="text-secondary text-center">
No DNS providers configured yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
) : (
<>
{providers?.length === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No DNS providers configured yet.
{isAdmin && (
<>
{" "}
<button className="btn btn-link p-0 align-baseline" onClick={() => setManagingProviders(true)}>
Add one
</button>
.
</>
)}
</div>
</div>
) : (
<>
<div className="mb-3" style={{ maxWidth: 320 }}>
<select
className="form-select"
value={selectedProviderId ?? ""}
onChange={(e) => setSelectedProviderId(Number(e.target.value))}
>
{providers?.map((p) => (
<option key={p.id} value={p.id} disabled={!p.enabled}>
{p.name} ({PROVIDER_LABELS[p.providerType]}){!p.enabled ? " — disabled" : ""}
</option>
))}
</select>
</div>
<div className="row g-3">
<div className="col-md-5">
<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>
<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>
{zonePageItems?.map((z) => (
<tr
key={z.id}
role="button"
className={selectedZone?.id === z.id ? "table-active" : ""}
onClick={() => selectZone(z)}
>
<td>{z.name}</td>
<td>{z.recordCount}</td>
<td className="text-secondary">
{z.syncedAt ? formatDateTime(new Date(z.syncedAt)) : "never"}
</td>
</tr>
))}
{sortedZones && sortedZones.length === 0 && (
<tr>
<td colSpan={3} className="text-secondary text-center">
No zones found for this provider.
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={zonePage} pageCount={zonePageCount} totalCount={zoneTotalCount} onPageChange={setZonePage} />
</div>
</div>
<div className="col-md-7">
{selectedZone ? (
<div className="card">
<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"}
</button>
)}
{canEdit && !recordForm && (
<button className="btn btn-sm btn-primary" onClick={startAddRecord}>
Add record
</button>
)}
</div>
</div>
{recordForm && (
<form onSubmit={submitRecord}>
<div className="card-body row g-2">
<div className="col-2">
<label className="form-label">Type</label>
<input
className="form-control"
required
list="dns-record-types"
value={recordForm.type}
onChange={(e) => setRecordForm({ ...recordForm, type: e.target.value.toUpperCase() })}
/>
<datalist id="dns-record-types">
{["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR"].map((t) => (
<option key={t} value={t} />
))}
</datalist>
</div>
<div className="col-3">
<label className="form-label">Name</label>
<input
className="form-control"
required
value={recordForm.name}
onChange={(e) => setRecordForm({ ...recordForm, name: e.target.value })}
/>
</div>
<div className="col-3">
<label className="form-label">Content</label>
<input
className="form-control"
required
value={recordForm.content}
onChange={(e) => setRecordForm({ ...recordForm, content: e.target.value })}
/>
</div>
<div className="col-2">
<label className="form-label">TTL</label>
<input
type="number"
className="form-control"
value={recordForm.ttl ?? ""}
onChange={(e) => setRecordForm({ ...recordForm, ttl: Number(e.target.value) || undefined })}
/>
</div>
<div className="col-2">
<label className="form-label">Priority</label>
<input
type="number"
className="form-control"
value={recordForm.priority ?? ""}
onChange={(e) =>
setRecordForm({ ...recordForm, priority: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</div>
{selectedProvider?.providerType === "cloudflare" && (
<div className="col-12">
<label className="form-check">
<input
type="checkbox"
className="form-check-input"
checked={Boolean(recordForm.proxied)}
onChange={(e) => setRecordForm({ ...recordForm, proxied: e.target.checked })}
/>
<span className="form-check-label">Proxied through Cloudflare</span>
</label>
</div>
)}
</div>
<div className="card-footer d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={savingRecord}>
{editingRecordId ? "Save changes" : "Add record"}
</button>
<button type="button" className="btn" onClick={() => setRecordForm(null)}>
Cancel
</button>
</div>
</form>
)}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<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>
{recordPageItems?.map((r) => (
<tr key={r.id}>
<td>
<span className="badge bg-blue-lt">{r.type}</span>
</td>
<td>{r.name}</td>
<td className="text-secondary">{r.content}</td>
<td>{r.ttl ?? "—"}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => startEditRecord(r)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => removeRecord(r)}>
Delete
</button>
</div>
</td>
)}
</tr>
))}
{sortedRecords?.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No cached records.{" "}
{syncedAt === null ? "Sync this zone to fetch them." : ""}
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={recordPage} pageCount={recordPageCount} totalCount={recordTotalCount} onPageChange={setRecordPage} />
</div>
) : (
<div className="card">
<div className="card-body text-secondary">Select a zone to view its records.</div>
</div>
)}
</div>
</div>
</>
)}
</>
)}
</>
);
}