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>
117 lines
4.8 KiB
TypeScript
117 lines
4.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api, type AuditLogEntry } from "../api/client";
|
|
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
|
import { useSortable } from "../hooks/useSortable";
|
|
import SortableTh from "../components/SortableTh";
|
|
import { usePagination } from "../hooks/usePagination";
|
|
import Pagination from "../components/Pagination";
|
|
import { downloadCsv } from "../utils/csv";
|
|
|
|
function targetLabel(e: AuditLogEntry): string {
|
|
if (!e.targetType) return "—";
|
|
let detail: Record<string, unknown> | null = null;
|
|
if (e.detail) {
|
|
try {
|
|
detail = JSON.parse(e.detail);
|
|
} catch {
|
|
detail = null;
|
|
}
|
|
}
|
|
const name = detail?.name ?? detail?.targetLabel ?? detail?.ipAddress;
|
|
const typeLabel = e.targetType.replace(/_/g, " ");
|
|
if (typeof name === "string" && name) return `${typeLabel} "${name}"`;
|
|
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() {
|
|
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.auditLog
|
|
.list()
|
|
.then((res) => setEntries(res.entries))
|
|
.catch((err) => setError(String(err)));
|
|
}, []);
|
|
|
|
const { sorted, sortKey, sortDir, requestSort } = useSortable(entries, "createdAt", "desc");
|
|
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
|
|
|
function exportCsv() {
|
|
if (!sorted) return;
|
|
downloadCsv(
|
|
"audit-log.csv",
|
|
["When", "Actor", "Category", "Action", "Target", "Details"],
|
|
sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e), detailSummary(e)]),
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="d-flex align-items-center mb-3">
|
|
<h2 className="page-title mb-0">Audit Log</h2>
|
|
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
|
|
Export CSV
|
|
</button>
|
|
</div>
|
|
{error && <div className="alert alert-danger">{error}</div>}
|
|
<div className="card">
|
|
<div className="table-responsive">
|
|
<table className="table table-vcenter card-table">
|
|
<thead>
|
|
<tr>
|
|
<SortableTh<AuditLogEntry> label="When" sortKeyName="createdAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<SortableTh<AuditLogEntry> label="Actor" sortKeyName="actorLabel" 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="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pageItems?.map((e) => (
|
|
<tr key={e.id}>
|
|
<td>{formatDateTime(parseDbTimestamp(e.createdAt))}</td>
|
|
<td>{e.actorLabel ?? "—"}</td>
|
|
<td>
|
|
<span className="badge bg-blue-lt">{e.category}</span>
|
|
</td>
|
|
<td>{e.action}</td>
|
|
<td>{targetLabel(e)}</td>
|
|
<td className="text-secondary small text-break" style={{ maxWidth: 420 }}>
|
|
{detailSummary(e) || "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{sorted?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="text-secondary text-center">
|
|
No activity recorded yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|