SPB Git

spb/findata

Public
4.1 KB · 86 lines tsx
1"use client";2import { useState } from "react";3import { RefreshCw } from "lucide-react";4import { api, type AuditRow } from "@/lib/api";5import { useAsync, useDebounced } from "@/lib/hooks";6import { useAuth } from "@/lib/auth";7import { fmtInt, fmtTs } from "@/lib/format";8import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";9import { DataTable } from "@/components/DataTable";10import { JsonView } from "@/components/JsonView";1112const ACTIONS = ["", "auth.", "auth.login", "auth.mfa", "admin.", "admin.enqueue", "admin.key_create", "admin.key_revoke", "export.", "query.sql"];13const HIDDEN = new Set(["id", "at", "action", "actor", "principal", "target", "outcome", "ip", "request_id", "detail", "user_agent"]);1415export default function AuditPage() {16  const { isOwner } = useAuth();17  const [action, setAction] = useState("");18  const [limit, setLimit] = useState(200);19  const da = useDebounced(action.trim(), 300);20  const { data, error, loading, reload } = useAsync((s) => api.get<{ data: AuditRow[] }>("/admin/audit", { action: da || undefined, limit }, s), [da, limit], { enabled: isOwner });2122  if (!isOwner) return <Banner tone="warn">The audit log is restricted to the owner.</Banner>;2324  return (25    <>26      <PageHeader27        title="Audit log"28        subtitle="Every authentication event, admin action, export and SQL query, with request ids."29        actions={30          <>31            <Field label="Action prefix" className="w-48">32              <input className="input py-1 font-mono text-xs" list="audit-actions" value={action} onChange={(e) => setAction(e.target.value)} placeholder="admin." />33              <datalist id="audit-actions">34                {ACTIONS.filter(Boolean).map((a) => (35                  <option key={a} value={a} />36                ))}37              </datalist>38            </Field>39            <Field label="Limit" className="w-24">40              <select className="input py-1 text-xs" value={limit} onChange={(e) => setLimit(Number(e.target.value))}>41                {[100, 200, 500, 1000, 2000].map((n) => (42                  <option key={n} value={n}>43                    {n}44                  </option>45                ))}46              </select>47            </Field>48            <button className="btn btn-sm self-end" onClick={reload}>49              <RefreshCw className="h-3.5 w-3.5" /> Refresh50            </button>51          </>52        }53      />54      {error && <ErrorState error={error} retry={reload} />}55      <Card>56        <DataTable57          rows={data?.data}58          loading={loading}59          rowKey={(r, i) => String(r.id ?? `${r.at}-${i}`)}60          mobile="cards"61          maxHeight="75dvh"62          columns={[63            { key: "at", header: "At (UTC)", render: (r) => <span className="tnum text-xs whitespace-nowrap">{fmtTs(r.at)}</span> },64            { key: "action", header: "Action", mono: true },65            { key: "actor", header: "Actor", render: (r) => <span className="text-xs">{String(r.actor ?? r.principal ?? "—")}</span> },66            { key: "target", header: "Target", render: (r) => <span className="font-mono text-xs break-all">{r.target ?? "—"}</span> },67            { key: "outcome", header: "Outcome", render: (r) => (r.outcome ? <StatusBadge status={r.outcome === "denied" ? "denied" : r.outcome === "ok" ? "ok" : r.outcome} label={r.outcome} /> : <span className="text-ink-3">—</span>) },68            { key: "ip", header: "IP", hideSm: true, mono: true },69            {70              key: "detail",71              header: "Detail",72              render: (r) => {73                const d = r.detail ?? Object.fromEntries(Object.entries(r).filter(([k]) => !HIDDEN.has(k)));74                return Object.keys(d).length ? <JsonView value={d} collapsed /> : <span className="text-ink-3">—</span>;75              },76            },77            { key: "request_id", header: "Request", hideSm: true, render: (r) => <span className="font-mono text-[11px] text-ink-3">{r.request_id ?? "—"}</span> },78          ]}79          empty="No audit entries match."80          footer={<span>{fmtInt(data?.data.length ?? 0)} entries</span>}81        />82      </Card>83    </>84  );85}86
Files · main
Menu

Theme