import { useEffect, useState, type CSSProperties } from "react"; import { Topbar } from "../components/Topbar"; import { Card, Badge, Btn, Avatar, Seg } from "../components/primitives"; import { Icons } from "../components/icons"; import { useAuditLogs } from "../lib/queries"; import { useT } from "../lib/i18n"; import { useUiStore } from "../lib/store"; import { fmtDateTime, fmtNumber } from "../lib/format"; type LevelFilter = "all" | "info" | "warn" | "error"; const PAGE_SIZES = [25, 50, 100, 200] as const; function levelBadge(level: string | undefined) { if (level === "error") return ( error ); if (level === "warn") return ( warn ); return ( {level ?? "info"} ); } function resultBadge(r: string | undefined) { if (!r) return null; if (r === "ok") return ( ok ); if (r === "denied") return ( denied ); if (r === "error") return ( error ); return {r}; } export function AuditLogsPage() { const t = useT(); const lang = useUiStore((s) => s.lang); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [searchInput, setSearchInput] = useState(""); const [search, setSearch] = useState(""); // debounced value used in query const [level, setLevel] = useState("all"); const [date, setDate] = useState(""); const [eventType, setEventType] = useState(""); // Debounce search so each keystroke doesn't refetch. useEffect(() => { const id = setTimeout(() => setSearch(searchInput), 300); return () => clearTimeout(id); }, [searchInput]); // Reset to page 1 whenever a filter changes so the user doesn't land on // an empty page. useEffect(() => { setPage(1); }, [search, level, date, eventType, pageSize]); const logs = useAuditLogs({ page, limit: pageSize, level: level === "all" ? undefined : level, search: search || undefined, date: date || undefined, eventType: eventType || undefined, }); const entries = logs.data?.entries ?? []; const total = logs.data?.total ?? 0; const totalPages = logs.data?.pages ?? 1; return ( <> logs.refetch()}> {t("refresh", "Refresh")} } />

{t("audit_logs", "Audit logs")}

{t( "audit.intro", "Every authentication, tool call, and config change. GDPR-compliant. Filters apply server-side.", )}
setSearchInput(e.target.value)} style={inputResetStyle} />
setEventType(e.target.value)} className="input" style={{ width: 220, fontFamily: "var(--font-mono)", fontSize: 12 }} /> setDate(e.target.value)} className="input" style={{ width: 170 }} title={t("audit.date_filter_title", "Filter by single day (YYYY-MM-DD)")} />
{logs.isLoading ? ( ) : entries.length === 0 ? ( ) : ( entries.map((r, i) => ( )) )}
{t("audit.col.time", "Time")} {t("audit.col.actor", "Actor")} {t("audit.col.event", "Event")} {t("audit.col.target", "Target")} {t("audit.col.message", "Message")} {t("audit.col.result", "Result")} {t("audit.col.level", "Level")} {t("audit.col.duration", "Duration")}
{t("audit.no_entries", "No matching entries.")} {search || level !== "all" || date || eventType ? ( <> {" "} { e.preventDefault(); setSearchInput(""); setSearch(""); setLevel("all"); setDate(""); setEventType(""); }} style={{ color: "var(--brand-400)" }} > {t("audit.clear_filters", "Clear filters")} ) : null}
{fmtDateTime(r.timestamp, lang)}
{r.actor ?? "system"} {r.ip ? ( ({r.ip}) ) : null}
{r.event_type} {r.target ?? "—"} {r.message ?? "—"} {resultBadge(r.result)} {levelBadge(r.level)} {r.duration_ms != null ? `${fmtNumber(r.duration_ms, lang)} ms` : "—"}
{total > 0 ? t("audit.range_of", "{from}–{to} of {total}") .replace("{from}", fmtNumber((page - 1) * pageSize + 1, lang)) .replace("{to}", fmtNumber(Math.min(page * pageSize, total), lang)) .replace("{total}", fmtNumber(total, lang)) : t("audit.zero_entries", "0 entries")} setPage((p) => p - 1)}> {lang === "fa" ? "→ " : "← "} {t("previous", "Previous")} {t("audit.page_label", "Page")}{" "} {fmtNumber(page, lang)} /{" "} {fmtNumber(totalPages, lang)} = totalPages} onClick={() => setPage((p) => p + 1)} > {t("next", "Next")} {lang === "fa" ? " ←" : " →"}
); } function SkeletonRows({ cols }: { cols: number }) { return ( <> {[0, 1, 2, 3, 4].map((i) => ( {Array.from({ length: cols }).map((_, j) => (
))} ))} ); } const filterRowStyle: CSSProperties = { display: "flex", gap: 10, alignItems: "center", marginBottom: 16, flexWrap: "wrap", }; const searchBoxStyle: CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "7px 10px", flex: 1, minWidth: 280, background: "var(--bg-sunken)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 13, }; const inputResetStyle: CSSProperties = { flex: 1, background: "none", border: "none", color: "var(--text)", outline: "none", }; const paginationStyle: CSSProperties = { display: "flex", gap: 12, justifyContent: "flex-end", alignItems: "center", marginTop: 16, };