From f4c61d4f5265c18600ff06101ff6f08dbaf1b3f3 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 02 Mar 2026 07:00:45 +0000 Subject: [PATCH] feat: add event logs to the dashboard --- web/src/app/(dashboard)/admins/page.tsx | 3 ++- web/src/app/(dashboard)/backfill/page.tsx | 4 ++-- web/src/app/(dashboard)/events/page.tsx | 384 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx | 2 +- web/src/app/(dashboard)/lexicons/page.tsx | 2 +- web/src/app/(dashboard)/page.tsx | 3 ++- web/src/app/(dashboard)/records/page.tsx | 4 ++-- web/src/components/app-sidebar.tsx | 2 ++ web/src/lib/api.ts | 118 ++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------- web/src/types/admins.ts | 6 ++++++ web/src/types/backfill.ts | 13 +++++++++++++ web/src/types/events.ts | 14 ++++++++++++++ web/src/types/lexicons.ts | 19 +++++++++++++++++++ web/src/types/network-lexicons.ts | 7 +++++++ web/src/types/records.ts | 10 ++++++++++ web/src/types/stats.ts | 9 +++++++++ web/src/types/tap.ts | 5 +++++ 17 file(s) changed, 521 insertion(s)(+), 84 deletion(s)(-) diff --git a/web/src/app/(dashboard)/admins/page.tsx b/web/src/app/(dashboard)/admins/page.tsx --- a/web/src/app/(dashboard)/admins/page.tsx +++ b/web/src/app/(dashboard)/admins/page.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "@/lib/auth-context"; -import { addAdmin, deleteAdmin, getAdmins, type AdminSummary } from "@/lib/api"; +import { addAdmin, deleteAdmin, getAdmins } from "@/lib/api"; +import type { AdminSummary } from "@/types/admins"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { Trash2 } from "lucide-react"; diff --git a/web/src/app/(dashboard)/backfill/page.tsx b/web/src/app/(dashboard)/backfill/page.tsx --- a/web/src/app/(dashboard)/backfill/page.tsx +++ b/web/src/app/(dashboard)/backfill/page.tsx @@ -8,9 +8,9 @@ createBackfillJob, getBackfillJobs, getLexicons, getTapStats, - type BackfillJob, - type TapStatsResponse, } from "@/lib/api"; +import type { BackfillJob } from "@/types/backfill"; +import type { TapStatsResponse } from "@/types/tap"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { diff --git a/web/src/app/(dashboard)/events/page.tsx b/web/src/app/(dashboard)/events/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/(dashboard)/events/page.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ColumnDef, + type VisibilityState, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { useAuth } from "@/lib/auth-context"; +import { getEvents } from "@/lib/api"; +import type { EventLogEntry } from "@/types/events"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; +import { CodeBlock } from "@/components/code-block"; +import { SiteHeader } from "@/components/site-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +const CATEGORIES = [ + { label: "All", value: "" }, + { label: "Lexicon", value: "lexicon" }, + { label: "Record", value: "record" }, + { label: "Script", value: "script" }, + { label: "Admin", value: "admin" }, + { label: "Backfill", value: "backfill" }, + { label: "Tap", value: "tap" }, +]; + +const SEVERITIES = [ + { label: "All", value: "" }, + { label: "Info", value: "info" }, + { label: "Warn", value: "warn" }, + { label: "Error", value: "error" }, +]; + +function severityBadge(severity: string) { + switch (severity) { + case "error": + return error; + case "warn": + return ( + + warn + + ); + default: + return info; + } +} + +function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const seconds = Math.floor((now - then) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export default function EventsPage() { + const { getToken } = useAuth(); + const [events, setEvents] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [viewEvent, setViewEvent] = useState(null); + + // Filters + const [category, setCategory] = useState(""); + const [severity, setSeverity] = useState(""); + const [subject, setSubject] = useState(""); + const subjectDebounce = useRef>(null); + + // Pagination + const [cursorStack, setCursorStack] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + + const fetchEvents = useCallback( + async (cursor?: string) => { + setLoading(true); + setError(null); + try { + const data = await getEvents(getToken, { + category: category || undefined, + severity: severity || undefined, + subject: subject || undefined, + cursor, + limit: 50, + }); + setEvents(data.events); + setNextCursor(data.cursor); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setEvents([]); + setNextCursor(null); + } finally { + setLoading(false); + } + }, + [getToken, category, severity, subject], + ); + + // Fetch on mount and when filters change (reset to first page) + useEffect(() => { + setCursorStack([]); + fetchEvents(); + }, [fetchEvents]); + + // Auto-refresh every 5s when on first page + useEffect(() => { + if (cursorStack.length > 0) return; + const interval = setInterval(() => fetchEvents(), 5000); + return () => clearInterval(interval); + }, [fetchEvents, cursorStack.length]); + + function handleSubjectChange(value: string) { + if (subjectDebounce.current) clearTimeout(subjectDebounce.current); + subjectDebounce.current = setTimeout(() => { + setSubject(value); + }, 300); + } + + function handleNext() { + if (!nextCursor) return; + setCursorStack((prev) => [...prev, nextCursor]); + fetchEvents(nextCursor); + } + + function handlePrevious() { + if (cursorStack.length === 0) return; + const stack = [...cursorStack]; + stack.pop(); + const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined; + setCursorStack(stack); + fetchEvents(prevCursor); + } + + function handleReset() { + setCategory(""); + setSeverity(""); + setSubject(""); + } + + const hasFilters = category !== "" || severity !== "" || subject !== ""; + + const columns = useMemo[]>( + () => [ + { + id: "severity", + accessorKey: "severity", + header: ({ column }) => ( + + ), + cell: ({ row }) => severityBadge(row.original.severity), + enableSorting: false, + enableHiding: false, + }, + { + id: "event_type", + accessorKey: "event_type", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.event_type} + ), + enableSorting: false, + }, + { + id: "subject", + accessorKey: "subject", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.subject ?? "--"} + + ), + enableSorting: false, + }, + { + id: "actor_did", + accessorKey: "actor_did", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.actor_did ?? "System"} + + ), + enableSorting: false, + }, + { + id: "created_at", + accessorKey: "created_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {timeAgo(row.original.created_at)} + + ), + enableSorting: false, + }, + ], + [], + ); + + const [columnVisibility, setColumnVisibility] = useState({}); + + const table = useReactTable({ + data: events, + columns, + state: { columnVisibility }, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + }); + + return ( + <> + +
+ {error &&

{error}

} + + +
+ + + + + handleSubjectChange(e.target.value)} + /> + + {hasFilters && ( + + )} +
+
+ +
+

+ {events.length} event(s) on this page. +

+
+ + +
+
+ + {viewEvent && ( + setViewEvent(null)}> + + + + {severityBadge(viewEvent.severity)} + + {viewEvent.event_type} + + + +
+
+ Subject +

+ {viewEvent.subject ?? "--"} +

+
+
+ Actor +

+ {viewEvent.actor_did ?? "System"} +

+
+
+ Time +

+ {new Date(viewEvent.created_at).toLocaleString()} +

+
+
+
+ Detail + +
+
+
+ )} +
+ + ); +} diff --git a/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx b/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx --- a/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx +++ b/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx @@ -10,8 +10,8 @@ deleteLexicon, deleteNetworkLexicon, getLexicon, uploadLexicon, - type LexiconDetail, } from "@/lib/api"; +import type { LexiconDetail } from "@/types/lexicons"; import { procedureScript, queryScript } from "@/lib/lua-templates"; import { useLuaCompletions } from "@/hooks/use-lua-completions"; import { SiteHeader } from "@/components/site-header"; diff --git a/web/src/app/(dashboard)/lexicons/page.tsx b/web/src/app/(dashboard)/lexicons/page.tsx --- a/web/src/app/(dashboard)/lexicons/page.tsx +++ b/web/src/app/(dashboard)/lexicons/page.tsx @@ -23,8 +23,8 @@ import { deleteLexicon, deleteNetworkLexicon, getLexicons, - type LexiconSummary, } from "@/lib/api"; +import type { LexiconSummary } from "@/types/lexicons"; import { DataTable } from "@/components/data-table/data-table"; import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; diff --git a/web/src/app/(dashboard)/page.tsx b/web/src/app/(dashboard)/page.tsx --- a/web/src/app/(dashboard)/page.tsx +++ b/web/src/app/(dashboard)/page.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from "react"; import { useAuth } from "@/lib/auth-context"; -import { getStats, type StatsResponse } from "@/lib/api"; +import { getStats } from "@/lib/api"; +import type { StatsResponse } from "@/types/stats"; import { SiteHeader } from "@/components/site-header"; import { Card, diff --git a/web/src/app/(dashboard)/records/page.tsx b/web/src/app/(dashboard)/records/page.tsx --- a/web/src/app/(dashboard)/records/page.tsx +++ b/web/src/app/(dashboard)/records/page.tsx @@ -16,9 +16,9 @@ getStats, getAdminRecords, deleteRecord, deleteCollectionRecords, - type CollectionStat, - type AdminRecord, } from "@/lib/api"; +import type { CollectionStat } from "@/types/stats"; +import type { AdminRecord } from "@/types/records"; import { AlertDialog, AlertDialogAction, diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -5,6 +5,7 @@ IconDashboard, IconFileDescription, IconDatabase, IconTable, + IconClipboardList, IconUsers, IconLogout, } from "@tabler/icons-react" @@ -30,6 +31,7 @@ { title: "Dashboard", url: "/", icon: IconDashboard }, { title: "Lexicons", url: "/lexicons", icon: IconFileDescription }, { title: "Backfill", url: "/backfill", icon: IconDatabase }, { title: "Records", url: "/records", icon: IconTable }, + { title: "Event Logs", url: "/events", icon: IconClipboardList }, { title: "Admins", url: "/admins", icon: IconUsers }, ] diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,23 @@ import { createDpopProof, setDpopNonce } from "./dpop" +import type { StatsResponse } from "@/types/stats" +import type { LexiconSummary, LexiconDetail } from "@/types/lexicons" +import type { NetworkLexiconSummary } from "@/types/network-lexicons" +import type { TapStatsResponse } from "@/types/tap" +import type { BackfillJob } from "@/types/backfill" +import type { AdminSummary } from "@/types/admins" +import type { AdminListRecordsResponse } from "@/types/records" +import type { EventsListResponse } from "@/types/events" + +export type { CollectionStat, StatsResponse } from "@/types/stats" +export type { LexiconSummary, LexiconDetail } from "@/types/lexicons" +export type { NetworkLexiconSummary } from "@/types/network-lexicons" +export type { TapStatsResponse } from "@/types/tap" +export type { BackfillJob } from "@/types/backfill" +export type { AdminSummary } from "@/types/admins" +export type { AdminRecord, AdminListRecordsResponse } from "@/types/records" +export type { EventLogEntry, EventsListResponse } from "@/types/events" + // The DPoP proof for admin API calls must target AIP's userinfo URL, // because the backend forwards the proof to AIP for token validation. // Set at runtime via ConfigProvider. @@ -68,41 +86,11 @@ return res.json() } // Stats -export interface CollectionStat { - collection: string - count: number -} - -export interface StatsResponse { - total_records: number - collections: CollectionStat[] -} - export function getStats(getToken: () => Promise) { return apiFetch("/admin/stats", getToken) } // Lexicons -export interface LexiconSummary { - id: string - revision: number - lexicon_type: string - backfill: boolean - action: string | null - target_collection: string | null - has_script: boolean - source: string - authority_did: string | null - last_fetched_at: string | null - created_at: string - updated_at: string -} - -export interface LexiconDetail extends LexiconSummary { - lexicon_json: Record - script: string | null -} - export function getLexicons(getToken: () => Promise) { return apiFetch("/admin/lexicons", getToken) } @@ -137,14 +125,6 @@ }) } // Network Lexicons -export interface NetworkLexiconSummary { - nsid: string - authority_did: string - target_collection: string | null - last_fetched_at: string | null - created_at: string -} - export function getNetworkLexicons(getToken: () => Promise) { return apiFetch("/admin/network-lexicons", getToken) } @@ -172,31 +152,11 @@ ) } // Tap Stats -export interface TapStatsResponse { - repo_count: number - record_count: number - outbox_buffer: number -} - export function getTapStats(getToken: () => Promise) { return apiFetch("/admin/tap/stats", getToken) } // Backfill -export interface BackfillJob { - id: string - collection: string | null - did: string | null - status: string - total_repos: number | null - processed_repos: number | null - total_records: number | null - error: string | null - started_at: string | null - completed_at: string | null - created_at: string -} - export function getBackfillJobs(getToken: () => Promise) { return apiFetch("/admin/backfill/status", getToken) } @@ -212,13 +172,6 @@ }) } // Admins -export interface AdminSummary { - id: string - did: string - created_at: string - last_used_at: string | null -} - export function getAdmins(getToken: () => Promise) { return apiFetch("/admin/admins", getToken) } @@ -254,17 +207,6 @@ return res.json() } // Admin records browsing -export interface AdminRecord { - uri: string - did: string - record: Record -} - -export interface AdminListRecordsResponse { - records: AdminRecord[] - cursor?: string -} - export function getAdminRecords( getToken: () => Promise, collection: string, @@ -301,3 +243,27 @@ getToken, { method: "DELETE" }, ) } + +// Event Logs +export function getEvents( + getToken: () => Promise, + params?: { + category?: string + severity?: string + subject?: string + cursor?: string + limit?: number + } +) { + const searchParams = new URLSearchParams() + if (params?.category) searchParams.set("category", params.category) + if (params?.severity) searchParams.set("severity", params.severity) + if (params?.subject) searchParams.set("subject", params.subject) + if (params?.cursor) searchParams.set("cursor", params.cursor) + if (params?.limit) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + return apiFetch( + `/admin/events${qs ? `?${qs}` : ""}`, + getToken + ) +} diff --git a/web/src/types/admins.ts b/web/src/types/admins.ts new file mode 100644 --- /dev/null +++ b/web/src/types/admins.ts @@ -0,0 +1,6 @@ +export interface AdminSummary { + id: string + did: string + created_at: string + last_used_at: string | null +} diff --git a/web/src/types/backfill.ts b/web/src/types/backfill.ts new file mode 100644 --- /dev/null +++ b/web/src/types/backfill.ts @@ -0,0 +1,13 @@ +export interface BackfillJob { + id: string + collection: string | null + did: string | null + status: string + total_repos: number | null + processed_repos: number | null + total_records: number | null + error: string | null + started_at: string | null + completed_at: string | null + created_at: string +} diff --git a/web/src/types/events.ts b/web/src/types/events.ts new file mode 100644 --- /dev/null +++ b/web/src/types/events.ts @@ -0,0 +1,14 @@ +export interface EventLogEntry { + id: string + event_type: string + severity: string + actor_did: string | null + subject: string | null + detail: Record + created_at: string +} + +export interface EventsListResponse { + events: EventLogEntry[] + cursor: string | null +} diff --git a/web/src/types/lexicons.ts b/web/src/types/lexicons.ts new file mode 100644 --- /dev/null +++ b/web/src/types/lexicons.ts @@ -0,0 +1,19 @@ +export interface LexiconSummary { + id: string + revision: number + lexicon_type: string + backfill: boolean + action: string | null + target_collection: string | null + has_script: boolean + source: string + authority_did: string | null + last_fetched_at: string | null + created_at: string + updated_at: string +} + +export interface LexiconDetail extends LexiconSummary { + lexicon_json: Record + script: string | null +} diff --git a/web/src/types/network-lexicons.ts b/web/src/types/network-lexicons.ts new file mode 100644 --- /dev/null +++ b/web/src/types/network-lexicons.ts @@ -0,0 +1,7 @@ +export interface NetworkLexiconSummary { + nsid: string + authority_did: string + target_collection: string | null + last_fetched_at: string | null + created_at: string +} diff --git a/web/src/types/records.ts b/web/src/types/records.ts new file mode 100644 --- /dev/null +++ b/web/src/types/records.ts @@ -0,0 +1,10 @@ +export interface AdminRecord { + uri: string + did: string + record: Record +} + +export interface AdminListRecordsResponse { + records: AdminRecord[] + cursor?: string +} diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts new file mode 100644 --- /dev/null +++ b/web/src/types/stats.ts @@ -0,0 +1,9 @@ +export interface CollectionStat { + collection: string + count: number +} + +export interface StatsResponse { + total_records: number + collections: CollectionStat[] +} diff --git a/web/src/types/tap.ts b/web/src/types/tap.ts new file mode 100644 --- /dev/null +++ b/web/src/types/tap.ts @@ -0,0 +1,5 @@ +export interface TapStatsResponse { + repo_count: number + record_count: number + outbox_buffer: number +} -- tangled.sh