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 = { 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, 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(null); const [managingProviders, setManagingProviders] = useState(false); const [selectedProviderId, setSelectedProviderId] = useState(null); const [error, setError] = useState(null); const [providerColors, setProviderColors] = useState>({}); useEffect(() => { api.settings.providerColors().then((res) => setProviderColors(res.providerColors)).catch(() => {}); }, []); const [zones, setZones] = useState(null); const [zonesError, setZonesError] = useState(null); const [selectedZone, setSelectedZone] = useState(null); const [records, setRecords] = useState(null); const [syncedAt, setSyncedAt] = useState(null); const [syncing, setSyncing] = useState(false); const [recordForm, setRecordForm] = useState(null); const [editingRecordId, setEditingRecordId] = useState(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 ( <>

DNS

{isAdmin && ( )}
{error &&
{error}
} {managingProviders ? ( <> { loadProviders(); }} onCancel={() => setManagingProviders(false)} />
label="Name" sortKeyName="name" activeKey={providerSortKey} direction={providerSortDir} onSort={requestProviderSort} /> label="Provider" sortKeyName="providerType" activeKey={providerSortKey} direction={providerSortDir} onSort={requestProviderSort} /> label="Status" sortKeyName="enabled" activeKey={providerSortKey} direction={providerSortDir} onSort={requestProviderSort} /> {sortedProviders?.map((p) => ( ))} {sortedProviders?.length === 0 && ( )}
Actions
{p.name} {PROVIDER_LABELS[p.providerType]}
No DNS providers configured yet.
) : ( <> {providers?.length === 0 ? (
No DNS providers configured yet. {isAdmin && ( <> {" "} . )}
) : ( <>

Zones

{zonesError &&
{zonesError}
}
label="Zone" sortKeyName="name" activeKey={zoneSortKey} direction={zoneSortDir} onSort={requestZoneSort} /> label="Records" sortKeyName="recordCount" activeKey={zoneSortKey} direction={zoneSortDir} onSort={requestZoneSort} /> label="Synced" sortKeyName="syncedAt" activeKey={zoneSortKey} direction={zoneSortDir} onSort={requestZoneSort} /> {zonePageItems?.map((z) => ( selectZone(z)} > ))} {sortedZones && sortedZones.length === 0 && ( )}
{z.name} {z.recordCount} {z.syncedAt ? formatDateTime(new Date(z.syncedAt)) : "never"}
No zones found for this provider.
{selectedZone ? (

{selectedZone.name}

{canEdit && ( )} {canEdit && !recordForm && ( )}
{recordForm && (
setRecordForm({ ...recordForm, type: e.target.value.toUpperCase() })} /> {["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR"].map((t) => (
setRecordForm({ ...recordForm, name: e.target.value })} />
setRecordForm({ ...recordForm, content: e.target.value })} />
setRecordForm({ ...recordForm, ttl: Number(e.target.value) || undefined })} />
setRecordForm({ ...recordForm, priority: e.target.value === "" ? undefined : Number(e.target.value) }) } />
{selectedProvider?.providerType === "cloudflare" && (
)}
)}
label="Type" sortKeyName="type" activeKey={recordSortKey} direction={recordSortDir} onSort={requestRecordSort} /> label="Name" sortKeyName="name" activeKey={recordSortKey} direction={recordSortDir} onSort={requestRecordSort} /> label="Content" sortKeyName="content" activeKey={recordSortKey} direction={recordSortDir} onSort={requestRecordSort} /> label="TTL" sortKeyName="ttl" activeKey={recordSortKey} direction={recordSortDir} onSort={requestRecordSort} /> {canEdit && } {recordPageItems?.map((r) => ( {canEdit && ( )} ))} {sortedRecords?.length === 0 && ( )}
Actions
{r.type} {r.name} {r.content} {r.ttl ?? "—"}
No cached records.{" "} {syncedAt === null ? "Sync this zone to fetch them." : ""}
) : (
Select a zone to view its records.
)}
)} )} ); }