Add a Diagnostic Log, ported and generalized from Sloth Manager

Sloth Manager tracked every API call made to DNS providers for
connectivity troubleshooting. Port that here, generalized to cover
every outbound integration this app makes, not just DNS -- Tailscale,
Proxmox, Synology, Semaphore, Gitea, and Dockhand calls now show up
too, since a broken API token or unreachable host on any of them is
just as worth diagnosing.

New services/diagLog.ts: a generic withDiagLogging(source, adapter)
wraps every async method of any adapter object with timing +
success/failure recording, without touching a single adapter's
request/error-handling internals -- every DNS and integration adapter
interface here is already just a flat set of async methods, so this
one wrapper works for all twelve of them. Applied it at each adapter
factory's own return statement (one line each) rather than at the
route layer, so background jobs that construct adapters directly
(the Tailscale key-expiry scheduler, IPAM sync, agent-driven Proxmox
lookups) get logged too, not just requests through routes/integrations.ts.

New diag_log table (ring-buffered to the last 500 rows, mirroring
Sloth Manager's approach -- this is for live troubleshooting, not a
durable record) and admin-only GET/DELETE /api/diag-log routes, source/
result filters, pagination.

New admin-only Diagnostic Log page: filterable, paginated table with a
Clear button. Also introduces the shared useSortable hook + SortableTh
component used here for the first time -- a follow-up commit applies
the same sorting (and CSV export) to the rest of the app's tables, per
the same request.

Verified end-to-end against a temp SQLite DB with real migrations: a
fake wrapped adapter's successful and failing calls both land correctly
in the log with the right source/operation/latency/error, and the
source/ok filters and clear-log operation all behave correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 21:41:06 +02:00
1 parent 42f073df81
commit e35da87886
26 files changed
+1592 -12

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { api, type DiagLogEntry } from "../api/client";
import { formatDateTime } from "../utils/date";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv";
const SOURCE_LABELS: Record<string, string> = {
cloudflare: "Cloudflare",
loopia: "Loopia",
pihole: "Pi-hole",
azure: "Azure DNS",
cpanel: "cPanel",
technitium: "Technitium",
tailscale: "Tailscale",
proxmox: "Proxmox",
synology: "Synology",
semaphore: "Semaphore",
gitea: "Gitea",
dockhand: "Dockhand",
};
const PAGE_SIZE = 100;
export default function DiagLog() {
const [entries, setEntries] = useState<DiagLogEntry[] | null>(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filterSource, setFilterSource] = useState("");
const [filterOk, setFilterOk] = useState("");
const [clearing, setClearing] = useState(false);
function load(pg: number) {
setLoading(true);
setError(null);
api.diagLog
.list({
source: filterSource || undefined,
ok: filterOk === "" ? undefined : filterOk === "true",
limit: PAGE_SIZE,
offset: pg * PAGE_SIZE,
})
.then((res) => {
setEntries(res.entries);
setTotal(res.total);
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoading(false));
}
useEffect(() => {
setPage(0);
load(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterSource, filterOk]);
function goToPage(pg: number) {
setPage(pg);
load(pg);
}
async function handleClear() {
if (!confirm("Clear the entire diagnostic log? This cannot be undone.")) return;
setClearing(true);
try {
await api.diagLog.clear();
setEntries([]);
setTotal(0);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setClearing(false);
}
}
const { sorted, sortKey, sortDir, requestSort } = useSortable(entries, "createdAt", "desc");
const totalPages = Math.ceil(total / PAGE_SIZE);
const errorCount = entries?.filter((e) => !e.ok).length ?? 0;
function exportCsv() {
if (!sorted) return;
downloadCsv(
"diagnostic-log.csv",
["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"],
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]),
);
}
return (
<>
<div className="d-flex align-items-center mb-3">
<div>
<h2 className="page-title mb-0">Diagnostic Log</h2>
<div className="text-secondary small mt-1">
Every call this app makes to a DNS provider or integration — the last 500 calls are kept. Use this to
troubleshoot connectivity issues.
</div>
</div>
<button className="btn btn-outline-danger ms-auto" onClick={handleClear} disabled={clearing || total === 0}>
{clearing ? "Clearing…" : "Clear log"}
</button>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<div className="card mb-3">
<div className="card-body">
<div className="row g-2 align-items-end">
<div className="col-md-3">
<label className="form-label">Source</label>
<select className="form-select" value={filterSource} onChange={(e) => setFilterSource(e.target.value)}>
<option value="">All sources</option>
{Object.entries(SOURCE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Result</label>
<select className="form-select" value={filterOk} onChange={(e) => setFilterOk(e.target.value)}>
<option value="">All results</option>
<option value="false">Errors only</option>
<option value="true">Success only</option>
</select>
</div>
<div className="col-md-6 d-flex justify-content-end gap-2">
<button className="btn btn-outline-secondary" onClick={() => load(page)} disabled={loading}>
{loading ? "Refreshing…" : "Refresh"}
</button>
<button className="btn btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
Export CSV
</button>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title">
{total} entries
{errorCount > 0 && <span className="text-red fw-normal ms-2">{errorCount} errors on this page</span>}
</h3>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<DiagLogEntry> label="Time" sortKeyName="createdAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DiagLogEntry> label="Source" sortKeyName="source" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DiagLogEntry> label="Operation" sortKeyName="operation" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DiagLogEntry> label="Result" sortKeyName="ok" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DiagLogEntry>
label="Latency"
sortKeyName="latencyMs"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<th>Error</th>
</tr>
</thead>
<tbody>
{sorted?.map((e) => (
<tr key={e.id}>
<td className="text-secondary" style={{ whiteSpace: "nowrap" }}>
{formatDateTime(new Date(e.createdAt))}
</td>
<td>{SOURCE_LABELS[e.source] ?? e.source}</td>
<td className="text-secondary">{e.operation}</td>
<td>
{e.ok ? (
<span className="badge bg-green-lt text-green">OK</span>
) : (
<span className="badge bg-red-lt text-red">Error</span>
)}
</td>
<td className={`text-secondary ${e.latencyMs > 2000 ? "text-yellow" : ""}`}>{e.latencyMs}ms</td>
<td className="text-red small" title={e.error ?? ""} style={{ maxWidth: 320, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{e.error ?? ""}
</td>
</tr>
))}
{sorted?.length === 0 && (
<tr>
<td colSpan={6} className="text-secondary text-center">
No diagnostic entries{filterSource || filterOk ? " match these filters" : " yet"}.
</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center justify-content-between">
<button className="btn btn-outline-secondary" onClick={() => goToPage(page - 1)} disabled={page === 0}>
← Prev
</button>
<span className="text-secondary">
Page {page + 1} of {totalPages}
</span>
<button className="btn btn-outline-secondary" onClick={() => goToPage(page + 1)} disabled={page >= totalPages - 1}>
Next →
</button>
</div>
)}
</div>
</>
);
}