import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client"; import { downloadCsv } from "../utils/csv"; import { formatDateTime, parseDbTimestamp } from "../utils/date"; import { formatAgo } from "../utils/duration"; import { readableError } from "../utils/errors"; const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [ { kind: "ip_conflict", title: "Address conflicts", blurb: "The same address reported by more than one server." }, { kind: "dns_stale", title: "DNS out of date", blurb: "A DNS record named after a server that points at an address the server doesn't have." }, { kind: "ipam_stale", title: "IPAM out of date", blurb: "An IPAM entry labelled with a server's name at an address the server doesn't have." }, { kind: "not_in_ipam", title: "Not in IPAM", blurb: "Addresses in use — reported by a server or pointed at by DNS — that IPAM doesn't list." }, { kind: "no_dns", title: "No DNS record", blurb: "Server addresses on your LAN that no DNS record points at." }, ]; const SEVERITY_BADGE: Record = { error: "bg-red-lt text-red", warning: "bg-yellow-lt text-yellow", info: "bg-blue-lt text-blue", }; const SEVERITY_LABEL: Record = { error: "Conflict", warning: "Mismatch", info: "Gap" }; const SEVERITY_PLURAL: Record = { error: "Conflicts", warning: "Mismatches", info: "Gaps" }; const INITIAL_SHOWN = 25; export default function Consistency({ user }: { user: CurrentUser }) { const canEdit = user.role === "admin" || user.role === "operator"; const [report, setReport] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [loading, setLoading] = useState(false); const [severity, setSeverity] = useState<"all" | ConsistencySeverity>("all"); const [busyKey, setBusyKey] = useState(null); const [expanded, setExpanded] = useState>(new Set()); const [showIgnored, setShowIgnored] = useState(false); const [newRange, setNewRange] = useState(""); const [savingRanges, setSavingRanges] = useState(false); function load() { setLoading(true); return api.consistency .report() .then((res) => { setReport(res); setError(null); }) .catch((err) => setError(readableError(err))) .finally(() => setLoading(false)); } useEffect(() => { void load(); }, []); const visible = useMemo(() => (report ? report.findings.filter((f) => severity === "all" || f.severity === severity) : []), [report, severity]); async function addToIpam(f: ConsistencyFinding) { if (!f.ip) return; setBusyKey(f.key); setError(null); setNotice(null); try { await api.ipam.create({ ipAddress: f.ip, label: f.suggestedLabel ?? undefined }); setNotice(`Added ${f.ip}${f.suggestedLabel ? ` as “${f.suggestedLabel}”` : ""} to IPAM.`); await load(); } catch (err) { setError(readableError(err)); } finally { setBusyKey(null); } } async function ignore(f: ConsistencyFinding) { const reason = window.prompt("Why is this fine? (optional — shown in the Ignored list)", ""); if (reason === null) return; // cancelled setBusyKey(f.key); setError(null); setNotice(null); try { await api.consistency.ignore(f.key, reason.trim() || undefined); await load(); } catch (err) { setError(readableError(err)); } finally { setBusyKey(null); } } /** Saves the whole list (the server validates and normalises it) and reloads, so the findings reflect it straight away. */ async function saveRanges(ranges: string[]): Promise { setSavingRanges(true); setError(null); setNotice(null); try { await api.consistency.setExcludedRanges(ranges); await load(); return true; } catch (err) { setError(readableError(err)); return false; } finally { setSavingRanges(false); } } async function addRange(e: React.FormEvent) { e.preventDefault(); if (!report || !newRange.trim()) return; if (await saveRanges([...report.excludedRanges, newRange.trim()])) setNewRange(""); } async function excludeAround(f: ConsistencyFinding) { if (!report || !f.ip) return; const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`; const range = window.prompt( `Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike. Range (a network like 192.168.16.0/20, or a single address):`, suggestion, ); if (range === null || !range.trim()) return; await saveRanges([...report.excludedRanges, range.trim()]); } async function restore(id: number) { setError(null); try { await api.consistency.unignore(id); await load(); } catch (err) { setError(readableError(err)); } } function exportCsv() { downloadCsv( "consistency.csv", ["Severity", "Type", "Address", "Finding", "Detail", "Servers", "DNS names"], visible.map((f) => [f.severity, f.kind, f.ip ?? "", f.title, f.detail, f.servers.map((s) => s.name).join(" "), f.dnsNames.join(" ")]), ); } const dns = report?.sources.dns; const dnsPartial = dns && dns.zones > 0 && dns.syncedZones < dns.zones; return ( <>

Consistency

Where IPAM, DNS and your servers disagree about what lives at an address. It compares what's already stored — nothing is fetched from the servers or DNS providers when you open this page.
{error &&
{error}
} {notice &&
{notice}
} {report && (
Compared {report.sources.servers.withAddresses} of {report.sources.servers.total} servers (those whose agent has reported addresses),{" "} {report.sources.ipam} IPAM {report.sources.ipam === 1 ? "entry" : "entries"} and {report.sources.dns.records} DNS records {dns && dns.zones > 0 && ( <> {" "} from {dns.syncedZones} of {dns.zones} synced zone{dns.zones === 1 ? "" : "s"} {dns.oldestSyncedAt && <> (oldest sync {formatAgo(dns.oldestSyncedAt)})} )} . {dnsPartial && ( <> {" "} Zones that haven't been synced have no records here — sync them on the DNS page. )}
)} {report && (
Excluded ranges
Addresses in these ranges are left out of the report completely — servers, IPAM and DNS alike. Meant for networks that aren't part of your LAN, like Docker's, which repeat the same subnet on many hosts. {report.hiddenAddresses > 0 && ( <> {" "} Currently hiding {report.hiddenAddresses} address{report.hiddenAddresses === 1 ? "" : "es"}. )}
{report.excludedRanges.length === 0 && Nothing excluded.} {report.excludedRanges.map((r) => ( {r} {canEdit && (
{canEdit && (
setNewRange(e.target.value)} />
)}
)} {report && (
{(["error", "warning", "info"] as ConsistencySeverity[]).map((s) => ( ))}
)} {report && report.findings.length === 0 && (
Nothing to report — everything compared lines up {report.sources.servers.withAddresses === 0 && " (no server has reported addresses yet, so there wasn't much to compare)"}.
)} {KINDS.map(({ kind, title, blurb }) => { const items = visible.filter((f) => f.kind === kind); if (items.length === 0) return null; const shown = expanded.has(kind) ? items : items.slice(0, INITIAL_SHOWN); return (

{title} · {items.length}

{blurb}
{shown.map((f) => (
{SEVERITY_LABEL[f.severity]}
{f.title}
{f.detail}
{f.servers.length > 0 && (
{f.servers.map((s, i) => ( {i > 0 && ", "} {s.name} ))}
)}
{canEdit && (
{f.kind === "not_in_ipam" && f.ip && ( )} {f.ip && ( )}
)}
))}
{items.length > INITIAL_SHOWN && (
)}
); })} {report && report.ignored.length > 0 && (

Ignored ({report.ignored.length})

{showIgnored && (
{report.ignored.map((i) => (
{i.title}
{i.reason ? `${i.reason} · ` : ""} {i.createdBy ? `${i.createdBy}, ` : ""} {formatDateTime(parseDbTimestamp(i.createdAt))} {!i.stillPresent && " · no longer occurring"}
{canEdit && ( )}
))}
)}
)} {report && (
Only private addresses are compared — public DNS records aren't expected to be in IPAM. Servers with no agent report, and IPv6 records (agents report IPv4 only), can't be judged against a server's addresses. Report generated{" "} {formatDateTime(new Date(report.generatedAt))}.
)} ); }