Files
Homelab-manager/web/src/pages/DiagLog.tsx
T
bobbanandClaude Sonnet 5.5 ad1fb5338f Replace the browser's confirm() and prompt() pop-ups with in-app dialogs
All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two
"exclude a range" boxes, tag rename) now use the app's own Tabler-styled
modals, which follow the light/dark theme.

A small promise-based API (utils/dialogs.ts: confirmDialog, promptDialog)
and a single DialogHost mounted once in App mean call sites just await
it in place of the browser call - no hooks or per-page modal state. Every
call site was already in an async function, so each is a one-line swap.

Each dialog now has a title and a main button that names the action
("Delete", "Stop now", "Run now") instead of "OK", with destructive ones
in red. Escape cancels, Enter confirms, Tab stays inside the dialog, the
page behind stops scrolling, and focus returns to the button that was
clicked. Destructive dialogs start with focus on Cancel so a stray Enter
can't delete anything. Text prompts pre-select their default and disable
the main button until something is typed where it's required, and still
tell cancelling (null) apart from confirming an empty box (""). Clicking
the backdrop cancels, but releasing a text selection over it doesn't.
Dialogs asked for together appear one after another.

Verified in a browser on a test page using the real component and the
app's real stylesheet: Escape, Enter, Tab trapping, focus handling,
required and optional prompts, backdrop clicks, queuing, scroll lock and
dark mode. The 31 call sites themselves were checked by search and
typecheck rather than clicked through in the logged-in app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:07:43 +02:00

215 lines
8.3 KiB
TypeScript

import { useEffect, useState } from "react";
import { api, type DiagLogEntry } from "../api/client";
import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
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 (!(await confirmDialog({ title: "Clear diagnostic log", message: "Clear the entire diagnostic log? This cannot be undone.", confirmLabel: "Clear log", danger: true }))) 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(parseDbTimestamp(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(parseDbTimestamp(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>
</>
);
}