Add a consistency report across IPAM, DNS and servers
New Consistency page listing where the three places this app records what lives at an address disagree: - Address conflicts: the same address reported by more than one server. - DNS out of date: a record named after a server (its hostname, or its short name) that points at an address the server doesn't report. - IPAM out of date: an entry labelled with a server's name at an address the server doesn't report. - Not in IPAM: addresses a server reports or DNS points at that IPAM doesn't list, merged into one finding per address, with a one-click "Add to IPAM" that pre-fills a label. - No DNS record: server LAN addresses no cached A/AAAA record resolves to. It compares data the app already holds and fetches nothing when opened, so the page states how many servers had reported addresses and how many DNS zones are synced (and how old the oldest sync is) -- DNS records are only cached for zones that have been synced, and a report that silently treated missing data as "no records" would mislead. Rules chosen to keep it from crying wolf: - Only private addresses are compared; public DNS records aren't expected to be in IPAM. - Servers with no reported addresses are never judged. - Agents report IPv4 only, so records are only compared within an address family (an AAAA record isn't "stale" for lacking an IPv6 address). - Docker bridge networks (172.16/12) are ignored: shared ones aren't conflicts, and they aren't listed unless someone put them in DNS. - Tailscale addresses don't need DNS records (MagicDNS), and IPAM entries kept current by the Tailscale/Proxmox syncs aren't second-guessed. Findings anyone has decided are fine can be ignored (operators) with a reason. An ignore is keyed on the finding's stable identity so it stays ignored across runs, its stored text comes from the finding rather than the request, and it is marked "no longer occurring" once the condition goes away. Ignore/restore are audit-logged. New table consistency_ignores (migration 0012). portScan's private-address helper is now exported and shared. Verified with 36 checks (each rule and its exclusions, address-family and case/trailing-dot handling, IPv6 case, ordering, stable keys, the report route including a garbled agent report, source counts, ignore/unignore rules and audit entries) and by driving the page against the real routers in a browser: Add to IPAM actually created the entry, ignore and restore, severity filter, "show all", the viewer view, and narrow-width layout (which found and fixed a squeezed badge and clipped buttons). Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca0fa817f8
commit
2352689fd3
13 files changed
+2246
-1
No files matched your search
@@ -22,6 +22,7 @@ import Synology from "./pages/Synology";
|
||||
import Generator from "./pages/Generator";
|
||||
import Maintenance from "./pages/Maintenance";
|
||||
import Domains from "./pages/Domains";
|
||||
import Consistency from "./pages/Consistency";
|
||||
import Settings from "./pages/Settings";
|
||||
import NotificationSettings from "./pages/settings/NotificationSettings";
|
||||
import BadgeSettings from "./pages/settings/BadgeSettings";
|
||||
@@ -90,6 +91,7 @@ export default function App() {
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
<Route path="/integrations" element={<Integrations user={user} />} />
|
||||
<Route path="/generator" element={<Generator />} />
|
||||
<Route path="/consistency" element={<Consistency user={user} />} />
|
||||
<Route path="/domains" element={<Domains user={user} />} />
|
||||
<Route path="/maintenance" element={<Maintenance user={user} />} />
|
||||
<Route path="/docker" element={<Docker user={user} />} />
|
||||
|
||||
@@ -513,6 +513,43 @@ export interface DomainList {
|
||||
checking: boolean;
|
||||
}
|
||||
|
||||
export type ConsistencyKind = "ip_conflict" | "dns_stale" | "ipam_stale" | "not_in_ipam" | "no_dns";
|
||||
export type ConsistencySeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface ConsistencyFinding {
|
||||
key: string;
|
||||
kind: ConsistencyKind;
|
||||
severity: ConsistencySeverity;
|
||||
title: string;
|
||||
detail: string;
|
||||
ip: string | null;
|
||||
servers: { id: number; name: string }[];
|
||||
dnsNames: string[];
|
||||
suggestedLabel: string | null;
|
||||
}
|
||||
|
||||
export interface ConsistencyIgnore {
|
||||
id: number;
|
||||
key: string;
|
||||
title: string;
|
||||
reason: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
stillPresent: boolean;
|
||||
}
|
||||
|
||||
export interface ConsistencyReport {
|
||||
findings: ConsistencyFinding[];
|
||||
counts: Record<ConsistencySeverity, number>;
|
||||
ignored: ConsistencyIgnore[];
|
||||
sources: {
|
||||
servers: { total: number; withAddresses: number };
|
||||
ipam: number;
|
||||
dns: { zones: number; syncedZones: number; records: number; oldestSyncedAt: string | null; newestSyncedAt: string | null };
|
||||
};
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface DockhandContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -877,6 +914,12 @@ export const api = {
|
||||
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
|
||||
},
|
||||
},
|
||||
consistency: {
|
||||
report: () => request<ConsistencyReport>("/api/consistency"),
|
||||
ignore: (key: string, reason?: string) =>
|
||||
request<{ ignore: ConsistencyIgnore }>("/api/consistency/ignore", { method: "POST", body: JSON.stringify({ key, reason }) }),
|
||||
unignore: (id: number) => request<void>(`/api/consistency/ignore/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
domains: {
|
||||
list: () => request<DomainList>("/api/domains"),
|
||||
add: (name: string) =>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
IconWand,
|
||||
IconTool,
|
||||
IconWorldWww,
|
||||
IconListCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
@@ -45,6 +46,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
|
||||
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
|
||||
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
||||
{ to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> },
|
||||
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
|
||||
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
||||
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
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 } 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<ConsistencySeverity, string> = {
|
||||
error: "bg-red-lt text-red",
|
||||
warning: "bg-yellow-lt text-yellow",
|
||||
info: "bg-blue-lt text-blue",
|
||||
};
|
||||
const SEVERITY_LABEL: Record<ConsistencySeverity, string> = { error: "Conflict", warning: "Mismatch", info: "Gap" };
|
||||
const SEVERITY_PLURAL: Record<ConsistencySeverity, string> = { 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<ConsistencyReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [severity, setSeverity] = useState<"all" | ConsistencySeverity>("all");
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<ConsistencyKind>>(new Set());
|
||||
const [showIgnored, setShowIgnored] = 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);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h2 className="page-title mb-0">Consistency</h2>
|
||||
<div className="ms-auto btn-list">
|
||||
<button className="btn btn-outline-secondary" onClick={exportCsv} disabled={visible.length === 0}>
|
||||
Export CSV
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? "Checking…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-secondary mb-3">
|
||||
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.
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{notice && <div className="alert alert-success">{notice}</div>}
|
||||
|
||||
{report && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-body py-2 text-secondary small">
|
||||
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 — <Link to="/dns">sync them on the DNS page</Link>.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="d-flex flex-wrap gap-2 mb-3">
|
||||
<button className={`btn btn-sm ${severity === "all" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setSeverity("all")}>
|
||||
All ({report.findings.length})
|
||||
</button>
|
||||
{(["error", "warning", "info"] as ConsistencySeverity[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`btn btn-sm ${severity === s ? "btn-primary" : "btn-outline-secondary"}`}
|
||||
onClick={() => setSeverity(s)}
|
||||
>
|
||||
{SEVERITY_PLURAL[s]} ({report.counts[s]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && report.findings.length === 0 && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-body text-secondary">
|
||||
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)"}.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div className="card mb-3" key={kind}>
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<h3 className="card-title">
|
||||
{title} <span className="text-secondary fw-normal">· {items.length}</span>
|
||||
</h3>
|
||||
<div className="text-secondary small">{blurb}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-group list-group-flush">
|
||||
{shown.map((f) => (
|
||||
<div className="list-group-item" key={f.key}>
|
||||
<div className="d-flex flex-wrap align-items-start gap-2">
|
||||
<span className={`badge mt-1 flex-shrink-0 ${SEVERITY_BADGE[f.severity]}`}>{SEVERITY_LABEL[f.severity]}</span>
|
||||
<div className="flex-fill" style={{ minWidth: "12rem" }}>
|
||||
<div>{f.title}</div>
|
||||
<div className="text-secondary small">{f.detail}</div>
|
||||
{f.servers.length > 0 && (
|
||||
<div className="small mt-1">
|
||||
{f.servers.map((s, i) => (
|
||||
<span key={s.id}>
|
||||
{i > 0 && ", "}
|
||||
<Link to={`/servers/${s.id}`}>{s.name}</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="btn-list flex-nowrap">
|
||||
{f.kind === "not_in_ipam" && f.ip && (
|
||||
<button className="btn btn-sm btn-outline-primary" onClick={() => void addToIpam(f)} disabled={busyKey === f.key}>
|
||||
Add to IPAM
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => void ignore(f)} disabled={busyKey === f.key}>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{items.length > INITIAL_SHOWN && (
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-link btn-sm p-0"
|
||||
onClick={() =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(kind)) next.delete(kind);
|
||||
else next.add(kind);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{expanded.has(kind) ? "Show fewer" : `Show all ${items.length}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{report && report.ignored.length > 0 && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Ignored ({report.ignored.length})</h3>
|
||||
<div className="card-actions">
|
||||
<button className="btn btn-link btn-sm p-0" onClick={() => setShowIgnored((s) => !s)}>
|
||||
{showIgnored ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showIgnored && (
|
||||
<div className="list-group list-group-flush">
|
||||
{report.ignored.map((i) => (
|
||||
<div className="list-group-item d-flex flex-wrap align-items-start gap-2" key={i.id}>
|
||||
<div className="flex-fill" style={{ minWidth: "12rem" }}>
|
||||
<div className={i.stillPresent ? "" : "text-secondary"}>{i.title}</div>
|
||||
<div className="text-secondary small">
|
||||
{i.reason ? `${i.reason} · ` : ""}
|
||||
{i.createdBy ? `${i.createdBy}, ` : ""}
|
||||
{formatDateTime(new Date(i.createdAt.includes("T") ? i.createdAt : `${i.createdAt.replace(" ", "T")}Z`))}
|
||||
{!i.stillPresent && " · no longer occurring"}
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => void restore(i.id)}>
|
||||
Stop ignoring
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="text-secondary small">
|
||||
Only private addresses are compared — public DNS records aren't expected to be in IPAM. Docker bridge networks (172.16–31.x)
|
||||
reported by agents are left out unless you've put them in DNS, and shared ones aren't counted as conflicts. 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))}.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user