step 9 and cleanup
Build and Push Docker Image / build (push) Successful in 1m4s

This commit is contained in:
jason
2026-04-22 09:27:01 -05:00
parent c8c86c9ca4
commit e0dfac2d48
18 changed files with 1521 additions and 85 deletions
+241
View File
@@ -0,0 +1,241 @@
import Link from "next/link";
import { getAuditLog, getAuditFacets, formatRelative } from "@/lib/reports";
export const dynamic = "force-dynamic";
/**
* Audit log viewer. Cursor-paginated, with action / entity / actor filters
* driven by the GET query string. Server-rendered so the URL is the source
* of truth — bookmarking a filtered view just works, and page reloads don't
* drop state.
*
* The JSON `before`/`after` payloads can be long; we render them lazily
* inside a <details> block so the table stays scannable.
*/
export default async function AuditPage({
searchParams,
}: {
searchParams: Promise<{
action?: string;
entity?: string;
actorId?: string;
cursor?: string;
}>;
}) {
const { action, entity, actorId, cursor } = await searchParams;
const [facets, { rows, nextCursor }] = await Promise.all([
getAuditFacets(),
getAuditLog({
limit: 50,
action: action || null,
entity: entity || null,
actorId: actorId || null,
cursor: cursor || null,
}),
]);
const now = new Date();
// Build a filter-preserving URL for the "Next page" link.
const nextHref = nextCursor
? `/admin/audit?${new URLSearchParams({
...(action ? { action } : {}),
...(entity ? { entity } : {}),
...(actorId ? { actorId } : {}),
cursor: nextCursor,
}).toString()}`
: null;
const filtersActive = !!(action || entity || actorId);
return (
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="flex items-start justify-between gap-6 mb-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
<p className="text-slate-500 mt-1 text-sm">
Every write claim, release, close, create, update, delete, login lands here.
Newest first.
</p>
</div>
</div>
{/* ─── Filter bar ──────────────────────────────────────────── */}
<form method="get" className="mb-6 rounded-xl bg-white border border-slate-200 p-4">
<div className="grid gap-3 md:grid-cols-4">
<label className="block text-sm">
<span className="text-slate-700">Action</span>
<select
name="action"
defaultValue={action ?? ""}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
>
<option value="">Any action</option>
{facets.actions.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="text-slate-700">Entity</span>
<select
name="entity"
defaultValue={entity ?? ""}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
>
<option value="">Any entity</option>
{facets.entities.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="text-slate-700">Actor</span>
<select
name="actorId"
defaultValue={actorId ?? ""}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
>
<option value="">Any user</option>
{facets.actors.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.role})
</option>
))}
</select>
</label>
<div className="flex items-end gap-2">
<button
type="submit"
className="h-10 rounded-md bg-slate-900 text-white text-sm font-medium px-4"
>
Filter
</button>
{filtersActive ? (
<Link
href="/admin/audit"
className="h-10 inline-flex items-center rounded-md border border-slate-300 text-sm font-medium px-4 text-slate-700 hover:bg-slate-50"
>
Clear
</Link>
) : null}
</div>
</div>
</form>
{/* ─── Results ─────────────────────────────────────────────── */}
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
{rows.length === 0 ? (
<p className="px-4 py-10 text-center text-slate-500 text-sm">
No events match. Clear filters or widen your search.
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium w-[140px]">When</th>
<th className="px-4 py-2 font-medium w-[150px]">Actor</th>
<th className="px-4 py-2 font-medium w-[150px]">Action</th>
<th className="px-4 py-2 font-medium w-[220px]">Entity</th>
<th className="px-4 py-2 font-medium">Payload</th>
<th className="px-4 py-2 font-medium w-[100px]">IP</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-0 align-top">
<td className="px-4 py-2 text-slate-600 whitespace-nowrap">
<div>{formatRelative(row.at, now)}</div>
<div className="text-xs text-slate-400">{row.at.toLocaleString()}</div>
</td>
<td className="px-4 py-2">
{row.actor ? (
<>
<div className="text-slate-900">{row.actor.name}</div>
<div className="text-xs text-slate-500">{row.actor.role}</div>
</>
) : (
<span className="text-slate-400 italic">system</span>
)}
</td>
<td className="px-4 py-2">
<span className="inline-flex rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-0.5 font-mono">
{row.action}
</span>
</td>
<td className="px-4 py-2 font-mono text-xs text-slate-700 break-all">
{row.entity}
{row.entityId ? <span className="text-slate-500">/{row.entityId}</span> : null}
</td>
<td className="px-4 py-2">
{row.before || row.after ? (
<details>
<summary className="cursor-pointer text-xs text-blue-600">
{row.after ? "after" : "before"}
{row.before && row.after ? " / before" : ""}
</summary>
<div className="mt-2 space-y-2">
{row.after ? (
<JsonBlock label="after" value={row.after} />
) : null}
{row.before ? (
<JsonBlock label="before" value={row.before} />
) : null}
</div>
</details>
) : (
<span className="text-slate-400 text-xs"></span>
)}
</td>
<td className="px-4 py-2 text-xs text-slate-500 font-mono">
{row.ipAddress ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ─── Pagination ──────────────────────────────────────────── */}
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
<div>{rows.length} row{rows.length === 1 ? "" : "s"} on this page</div>
{nextHref ? (
<Link
href={nextHref}
className="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm hover:bg-slate-50"
>
Older
</Link>
) : (
<span className="text-slate-400">End of log</span>
)}
</div>
</div>
);
}
/**
* Pretty-prints a JSON audit payload. Falls back to the raw string if the
* row happens to contain non-JSON (older rows, or writes that logged a
* free-form message).
*/
function JsonBlock({ label, value }: { label: string; value: string }) {
let pretty = value;
try {
pretty = JSON.stringify(JSON.parse(value), null, 2);
} catch {
// leave as-is
}
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-500">{label}</div>
<pre className="mt-1 text-xs bg-slate-50 border border-slate-200 rounded-md p-2 overflow-x-auto">
{pretty}
</pre>
</div>
);
}