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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5.5 committed 2026-10-02 22:40:35 +02:00
1 parent 21054aaa8c
commit 22cfdbede0
7 files changed
+55 -15

No files matched your search

+2 -1
View File
@@ -11,6 +11,7 @@ auditLogRouter.use(requireAuth, requireRole("operator"));
auditLogRouter.get("/", asyncHandler(async (req, res) => { auditLogRouter.get("/", asyncHandler(async (req, res) => {
const limit = Math.min(Number(req.query.limit ?? 200), 500); 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 }); res.json({ entries: rows });
})); }));
+8
View File
@@ -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" }); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const row = await checkDomain(id); const row = await checkDomain(id);
if (!row) return res.status(404).json({ error: "not_found" }); 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(); const { healthChecks } = await getSettings();
res.json({ domain: present(row, healthChecks.domainWarnDays) }); res.json({ domain: present(row, healthChecks.domainWarnDays) });
})); }));
+6 -4
View File
@@ -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() }); 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" }); if (!server) return res.status(404).json({ error: "not_found" });
const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning(); 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", action: "add_link",
targetType: "server", targetType: "server",
targetId: serverId, 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 } }); 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(); .returning();
if (!updated) return res.status(404).json({ error: "not_found" }); 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({ await recordAudit({
actor: req.currentUser!, actor: req.currentUser!,
category: "server", category: "server",
action: "update_link", action: "update_link",
targetType: "server", targetType: "server",
targetId: serverId, 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 } }); 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(); .returning();
if (deleted.length === 0) return res.status(404).json({ error: "not_found" }); 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({ await recordAudit({
actor: req.currentUser!, actor: req.currentUser!,
category: "server", category: "server",
action: "remove_link", action: "remove_link",
targetType: "server", targetType: "server",
targetId: serverId, targetId: serverId,
detail: { label: deleted[0].label }, detail: { name: owner?.name, label: deleted[0].label, url: deleted[0].url },
}); });
res.status(204).end(); res.status(204).end();
+25 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type AuditLogEntry } from "../api/client"; import { api, type AuditLogEntry } from "../api/client";
import { formatDateTime } from "../utils/date"; import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
@@ -23,6 +23,22 @@ function targetLabel(e: AuditLogEntry): string {
return `${typeLabel}${e.targetId ? ` #${e.targetId}` : ""}`; 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<string, unknown>)
.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() { export default function AuditLog() {
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null); const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -41,8 +57,8 @@ export default function AuditLog() {
if (!sorted) return; if (!sorted) return;
downloadCsv( downloadCsv(
"audit-log.csv", "audit-log.csv",
["When", "Actor", "Category", "Action", "Target"], ["When", "Actor", "Category", "Action", "Target", "Details"],
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e)]), 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() {
<SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<th>Details</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{pageItems?.map((e) => ( {pageItems?.map((e) => (
<tr key={e.id}> <tr key={e.id}>
<td>{formatDateTime(new Date(e.createdAt))}</td> <td>{formatDateTime(parseDbTimestamp(e.createdAt))}</td>
<td>{e.actorLabel ?? "—"}</td> <td>{e.actorLabel ?? "—"}</td>
<td> <td>
<span className="badge bg-blue-lt">{e.category}</span> <span className="badge bg-blue-lt">{e.category}</span>
</td> </td>
<td>{e.action}</td> <td>{e.action}</td>
<td>{targetLabel(e)}</td> <td>{targetLabel(e)}</td>
<td className="text-secondary small text-break" style={{ maxWidth: 420 }}>
{detailSummary(e) || "—"}
</td>
</tr> </tr>
))} ))}
{sorted?.length === 0 && ( {sorted?.length === 0 && (
<tr> <tr>
<td colSpan={5} className="text-secondary text-center"> <td colSpan={6} className="text-secondary text-center">
No activity recorded yet. No activity recorded yet.
</td> </td>
</tr> </tr>
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client"; import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { formatDateTime } from "../utils/date"; import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { formatAgo } from "../utils/duration"; import { formatAgo } from "../utils/duration";
import { readableError } from "../utils/errors"; import { readableError } from "../utils/errors";
@@ -356,7 +356,7 @@ Range (a network like 192.168.16.0/20, or a single address):`,
<div className="text-secondary small"> <div className="text-secondary small">
{i.reason ? `${i.reason} · ` : ""} {i.reason ? `${i.reason} · ` : ""}
{i.createdBy ? `${i.createdBy}, ` : ""} {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"} {!i.stillPresent && " · no longer occurring"}
</div> </div>
</div> </div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type DiagLogEntry } from "../api/client"; import { api, type DiagLogEntry } from "../api/client";
import { formatDateTime } from "../utils/date"; import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
@@ -84,7 +84,7 @@ export default function DiagLog() {
downloadCsv( downloadCsv(
"diagnostic-log.csv", "diagnostic-log.csv",
["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"], ["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) => ( {sorted?.map((e) => (
<tr key={e.id}> <tr key={e.id}>
<td className="text-secondary" style={{ whiteSpace: "nowrap" }}> <td className="text-secondary" style={{ whiteSpace: "nowrap" }}>
{formatDateTime(new Date(e.createdAt))} {formatDateTime(parseDbTimestamp(e.createdAt))}
</td> </td>
<td>{SOURCE_LABELS[e.source] ?? e.source}</td> <td>{SOURCE_LABELS[e.source] ?? e.source}</td>
<td className="text-secondary">{e.operation}</td> <td className="text-secondary">{e.operation}</td>
+9
View File
@@ -17,6 +17,15 @@ export function is24HourFormat(): boolean {
return current.timeFormat === "24h"; 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 { function pad(n: number): string {
return String(n).padStart(2, "0"); return String(n).padStart(2, "0");
} }