From 22cfdbede0284542cde0b76d21c6c4e3a0cd1df5 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Fri, 2 Oct 2026 22:40:35 +0200 Subject: [PATCH] Fix how the audit log displays entries; record admin-link and domain checks 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 --- server/src/routes/auditLog.ts | 3 ++- server/src/routes/domains.ts | 8 ++++++++ server/src/routes/servers.ts | 10 ++++++---- web/src/pages/AuditLog.tsx | 30 +++++++++++++++++++++++++----- web/src/pages/Consistency.tsx | 4 ++-- web/src/pages/DiagLog.tsx | 6 +++--- web/src/utils/date.ts | 9 +++++++++ 7 files changed, 55 insertions(+), 15 deletions(-) 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"); }