Entries were stored in UTC without a zone marker and the Audit and Diagnostic Log pages read them as local time, so every entry showed shifted by the viewer's UTC offset (two hours early in Sweden). A shared parseDbTimestamp() now reads them as UTC, and replaces the inline workaround the Consistency page had. The Audit Log never displayed an entry's details at all, so adding an admin link showed only "server #1". Link entries now carry the server name and the label/URL, and a new Details column shows them, along with things like a port scan's address and range. Entries made within the same second are now ordered by id instead of arbitrarily. A domain's "Check now" was the one user-triggered action that wasn't audited; it is now. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
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<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);
|
|
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<boolean> {
|
|
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 (
|
|
<>
|
|
<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="card mb-3">
|
|
<div className="card-body">
|
|
<div className="fw-bold">Excluded ranges</div>
|
|
<div className="text-secondary small mb-2">
|
|
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"}.
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="d-flex flex-wrap gap-1 mb-2">
|
|
{report.excludedRanges.length === 0 && <span className="text-secondary small">Nothing excluded.</span>}
|
|
{report.excludedRanges.map((r) => (
|
|
<span key={r} className="badge bg-secondary-lt text-secondary d-inline-flex align-items-center">
|
|
<code className="bg-transparent p-0">{r}</code>
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
className="btn-close ms-1"
|
|
style={{ fontSize: "0.5rem" }}
|
|
aria-label={`Stop excluding ${r}`}
|
|
disabled={savingRanges}
|
|
onClick={() => void saveRanges(report.excludedRanges.filter((x) => x !== r))}
|
|
/>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{canEdit && (
|
|
<form onSubmit={addRange} className="d-flex gap-2" style={{ maxWidth: 460 }}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
placeholder="e.g. 192.168.16.0/20 or 10.1.2.3"
|
|
value={newRange}
|
|
onChange={(e) => setNewRange(e.target.value)}
|
|
/>
|
|
<button type="submit" className="btn btn-sm btn-primary" disabled={savingRanges || !newRange.trim()}>
|
|
Exclude
|
|
</button>
|
|
</form>
|
|
)}
|
|
</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>
|
|
)}
|
|
{f.ip && (
|
|
<button className="btn btn-sm btn-outline-secondary" onClick={() => void excludeAround(f)} disabled={savingRanges} title="Leave a whole range out of the report">
|
|
Exclude range
|
|
</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(parseDbTimestamp(i.createdAt))}
|
|
{!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. 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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|