diff --git a/server/src/routes/auditLog.ts b/server/src/routes/auditLog.ts index cffc0a3..a84d88a 100644 --- a/server/src/routes/auditLog.ts +++ b/server/src/routes/auditLog.ts @@ -11,6 +11,7 @@ auditLogRouter.use(requireAuth, requireRole("operator")); auditLogRouter.get("/", asyncHandler(async (req, res) => { const limit = Math.min(Number(req.query.limit ?? 200), 500); - const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit); + // createdAt only has one-second resolution, so entries made within the same second are ordered by id. + const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt), desc(auditLog.id)).limit(limit); res.json({ entries: rows }); })); diff --git a/server/src/routes/domains.ts b/server/src/routes/domains.ts index 1441f62..c667f6a 100644 --- a/server/src/routes/domains.ts +++ b/server/src/routes/domains.ts @@ -60,6 +60,14 @@ domainsRouter.post("/:id/check", requireRole("operator"), asyncHandler(async (re if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const row = await checkDomain(id); if (!row) return res.status(404).json({ error: "not_found" }); + await recordAudit({ + actor: req.currentUser!, + category: "domain", + action: "check", + targetType: "domain", + targetId: id, + detail: { name: row.name, error: row.lastCheckError }, + }); const { healthChecks } = await getSettings(); res.json({ domain: present(row, healthChecks.domainWarnDays) }); })); diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index d33914c..50da597 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -341,7 +341,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } - const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, serverId)).limit(1); + const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1); if (!server) return res.status(404).json({ error: "not_found" }); const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning(); @@ -352,7 +352,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re action: "add_link", targetType: "server", targetId: serverId, - detail: { label: created.label, url: created.url }, + detail: { name: server.name, label: created.label, url: created.url }, }); res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } }); @@ -375,13 +375,14 @@ serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler( .returning(); if (!updated) return res.status(404).json({ error: "not_found" }); + const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1); await recordAudit({ actor: req.currentUser!, category: "server", action: "update_link", targetType: "server", targetId: serverId, - detail: { label: updated.label, url: updated.url }, + detail: { name: owner?.name, label: updated.label, url: updated.url }, }); res.json({ link: { id: updated.id, label: updated.label, url: updated.url } }); @@ -398,13 +399,14 @@ serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler .returning(); if (deleted.length === 0) return res.status(404).json({ error: "not_found" }); + const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1); await recordAudit({ actor: req.currentUser!, category: "server", action: "remove_link", targetType: "server", targetId: serverId, - detail: { label: deleted[0].label }, + detail: { name: owner?.name, label: deleted[0].label, url: deleted[0].url }, }); res.status(204).end(); diff --git a/web/src/pages/AuditLog.tsx b/web/src/pages/AuditLog.tsx index a0f6192..c911040 100644 --- a/web/src/pages/AuditLog.tsx +++ b/web/src/pages/AuditLog.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { api, type AuditLogEntry } from "../api/client"; -import { formatDateTime } from "../utils/date"; +import { formatDateTime, parseDbTimestamp } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; @@ -23,6 +23,22 @@ function targetLabel(e: AuditLogEntry): string { return `${typeLabel}${e.targetId ? ` #${e.targetId}` : ""}`; } +/** What was done, beyond the target — the link's label and URL, a scan's address and range, and so on. The name is already in the Target column. */ +function detailSummary(e: AuditLogEntry): string { + if (!e.detail) return ""; + let parsed: unknown; + try { + parsed = JSON.parse(e.detail); + } catch { + return e.detail; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return String(parsed); + return Object.entries(parsed as Record) + .filter(([key, value]) => key !== "name" && value !== null && value !== undefined && value !== "" && !(Array.isArray(value) && value.length === 0)) + .map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`) + .join(" · "); +} + export default function AuditLog() { const [entries, setEntries] = useState(null); const [error, setError] = useState(null); @@ -41,8 +57,8 @@ export default function AuditLog() { if (!sorted) return; downloadCsv( "audit-log.csv", - ["When", "Actor", "Category", "Action", "Target"], - sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e)]), + ["When", "Actor", "Category", "Action", "Target", "Details"], + sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e), detailSummary(e)]), ); } @@ -65,23 +81,27 @@ export default function AuditLog() { label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + Details {pageItems?.map((e) => ( - {formatDateTime(new Date(e.createdAt))} + {formatDateTime(parseDbTimestamp(e.createdAt))} {e.actorLabel ?? "—"} {e.category} {e.action} {targetLabel(e)} + + {detailSummary(e) || "—"} + ))} {sorted?.length === 0 && ( - + No activity recorded yet. diff --git a/web/src/pages/Consistency.tsx b/web/src/pages/Consistency.tsx index 3fcc784..e68b53b 100644 --- a/web/src/pages/Consistency.tsx +++ b/web/src/pages/Consistency.tsx @@ -2,7 +2,7 @@ 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 { formatDateTime, parseDbTimestamp } from "../utils/date"; import { formatAgo } from "../utils/duration"; import { readableError } from "../utils/errors"; @@ -356,7 +356,7 @@ Range (a network like 192.168.16.0/20, or a single address):`,
{i.reason ? `${i.reason} · ` : ""} {i.createdBy ? `${i.createdBy}, ` : ""} - {formatDateTime(new Date(i.createdAt.includes("T") ? i.createdAt : `${i.createdAt.replace(" ", "T")}Z`))} + {formatDateTime(parseDbTimestamp(i.createdAt))} {!i.stillPresent && " · no longer occurring"}
diff --git a/web/src/pages/DiagLog.tsx b/web/src/pages/DiagLog.tsx index a511a33..4089ad5 100644 --- a/web/src/pages/DiagLog.tsx +++ b/web/src/pages/DiagLog.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { api, type DiagLogEntry } from "../api/client"; -import { formatDateTime } from "../utils/date"; +import { formatDateTime, parseDbTimestamp } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; @@ -84,7 +84,7 @@ export default function DiagLog() { 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 ?? ""]), + sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]), ); } @@ -167,7 +167,7 @@ export default function DiagLog() { {sorted?.map((e) => ( - {formatDateTime(new Date(e.createdAt))} + {formatDateTime(parseDbTimestamp(e.createdAt))} {SOURCE_LABELS[e.source] ?? e.source} {e.operation} diff --git a/web/src/utils/date.ts b/web/src/utils/date.ts index ddf27e3..a0daeb7 100644 --- a/web/src/utils/date.ts +++ b/web/src/utils/date.ts @@ -17,6 +17,15 @@ export function is24HourFormat(): boolean { return current.timeFormat === "24h"; } +/** + * Parses a timestamp the server stored. SQLite's own `current_timestamp` ("2026-10-02 20:27:55") is UTC but carries no + * zone marker, and `new Date()` would read that as local time — showing every entry shifted by the viewer's UTC offset. + * Timestamps the app wrote itself are ISO strings with a zone and parse as they are. + */ +export function parseDbTimestamp(value: string): Date { + return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value.replace(" ", "T")}Z`); +} + function pad(n: number): string { return String(n).padStart(2, "0"); }