Files
Homelab-manager/web/src/pages/AuditLog.tsx
T
bobbanandClaude Sonnet 5.5 22cfdbede0 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>
2026-10-02 22:40:35 +02:00

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>
</>
);
}