Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { type ColumnDef, type ColumnFiltersState, type VisibilityState, getCoreRowModel, useReactTable,} from "@tanstack/react-table";
import { getEvents, type EventLogEntry } from "@/lib/api";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";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 { ResponsiveDialog, ResponsiveDialogContent, ResponsiveDialogHeader, ResponsiveDialogTitle,} from "@/components/ui/responsive-dialog";import { ChevronLeft, ChevronRight } from "lucide-react";
function severityBadge(severity: string) { switch (severity) { case "error": return <Badge variant="destructive">error</Badge>; case "warn": return ( <Badge className="bg-amber-500/15 text-amber-700 dark:text-amber-400 hover:bg-amber-500/25 border-amber-500/20"> warn </Badge> ); default: return <Badge variant="secondary">info</Badge>; }}
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`;}
/** Known detail keys that get their own sections — excluded from the "Other" dump. */const KNOWN_KEYS = [ "error", "errorType", "method", "line", "input", "params", "response", "script_source", "caller_did", "duration_ms", "response_size", "message", "level",] as const;
function EventDetailBody({ event }: { event: EventLogEntry }) { const d = event.detail;
// Collect any keys not in KNOWN_KEYS for the "Other" section const otherKeys = Object.keys(d).filter( (k) => !(KNOWN_KEYS as readonly string[]).includes(k), ); const otherDetail = otherKeys.length > 0 ? Object.fromEntries(otherKeys.map((k) => [k, d[k]])) : null;
return ( <div className="flex flex-col gap-4"> {/* Metadata grid */} <div className="grid grid-cols-2 gap-4 text-sm"> <div> <span className="text-muted-foreground">Subject</span> <p className="font-mono text-xs">{event.subject ?? "--"}</p> </div> <div> <span className="text-muted-foreground">Actor</span> <p className="font-mono text-xs"> {event.actor_did ?? "System"} </p> </div> <div> <span className="text-muted-foreground">Time</span> <p className="text-xs"> {new Date(event.created_at).toLocaleString()} </p> </div> {d.method != null && ( <div> <span className="text-muted-foreground">Method</span> <p className="font-mono text-xs">{String(d.method)}</p> </div> )} {d.caller_did != null && ( <div> <span className="text-muted-foreground">Caller DID</span> <p className="font-mono text-xs">{String(d.caller_did)}</p> </div> )} {d.duration_ms != null && ( <div> <span className="text-muted-foreground">Duration</span> <p className="text-xs tabular-nums">{String(d.duration_ms)}ms</p> </div> )} {d.response_size != null && ( <div> <span className="text-muted-foreground">Response Size</span> <p className="text-xs tabular-nums"> {Number(d.response_size).toLocaleString()} bytes </p> </div> )} </div>
{/* Plugin log message */} {d.message != null && ( <div> <div className="flex items-center gap-2"> <span className="text-muted-foreground text-sm">Message</span> {d.level != null && ( <Badge variant="outline" className="text-xs uppercase"> {String(d.level)} </Badge> )} </div> <p className="mt-1 whitespace-pre-wrap break-words font-mono text-xs"> {String(d.message)} </p> </div> )}
{/* Error section */} {d.error != null && ( <div> <span className="text-muted-foreground text-sm">Error</span> <div className="bg-destructive/10 text-destructive mt-1 rounded-md p-3 font-mono text-xs"> {d.errorType != null && ( <Badge variant="destructive" className="mb-2 mr-2"> {String(d.errorType)} </Badge> )} {d.line != null && ( <Badge variant="outline" className="mb-2"> line {String(d.line)} </Badge> )} <p className="mt-1">{String(d.error)}</p> </div> </div> )}
{/* Request input/params */} {(d.input != null || d.params != null) && ( <div> <span className="text-muted-foreground text-sm"> {d.input != null ? "Request Input" : "Request Params"} </span> <CodeBlock code={JSON.stringify(d.input ?? d.params, null, 2)} className="mt-1 max-h-64 rounded-md" /> </div> )}
{/* Response */} {d.response != null && ( <div> <span className="text-muted-foreground text-sm">Response</span> <CodeBlock code={JSON.stringify(d.response, null, 2)} className="mt-1 max-h-96 rounded-md" /> </div> )}
{/* Script source */} {d.script_source != null && ( <div> <span className="text-muted-foreground text-sm">Script Source</span> <CodeBlock code={String(d.script_source)} lang="lua" className="mt-1 max-h-64 rounded-md" /> </div> )}
{/* Other detail fields */} {otherDetail && ( <div> <span className="text-muted-foreground text-sm">Other</span> <CodeBlock code={JSON.stringify(otherDetail, null, 2)} className="mt-1 rounded-md" /> </div> )} </div> );}
export default function EventsPage() { const [events, setEvents] = useState<EventLogEntry[]>([]); const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [viewEvent, setViewEvent] = useState<EventLogEntry | null>(null);
// Pagination const [cursorStack, setCursorStack] = useState<string[]>([]); const [nextCursor, setNextCursor] = useState<string | null>(null);
// Filters — driven by TanStack column filter state const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// Debounce subject filter to avoid firing on every keystroke const debounceRef = useRef<ReturnType<typeof setTimeout>>(null); const [debouncedFilters, setDebouncedFilters] = useState<ColumnFiltersState>(columnFilters);
useEffect(() => { const subjectFilter = columnFilters.find((f) => f.id === "subject"); const prevSubjectFilter = debouncedFilters.find((f) => f.id === "subject"); const subjectChanged = subjectFilter?.value !== prevSubjectFilter?.value;
if (subjectChanged) { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setDebouncedFilters(columnFilters); }, 300); } else { setDebouncedFilters(columnFilters); } }, [columnFilters]);
const fetchEvents = useCallback( async (cursor?: string) => { setLoading(true); setError(null); try { const categoryFilter = debouncedFilters.find( (f) => f.id === "event_type", )?.value as string[] | undefined; const severityFilter = debouncedFilters.find((f) => f.id === "severity") ?.value as string[] | undefined; const subjectFilter = debouncedFilters.find((f) => f.id === "subject") ?.value as string | undefined;
const data = await getEvents({ category: categoryFilter?.length ? categoryFilter.join(",") : undefined, severity: severityFilter?.length ? severityFilter.join(",") : undefined, subject: subjectFilter || 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); } }, [debouncedFilters], );
// 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 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); }
const columns = useMemo<ColumnDef<EventLogEntry>[]>( () => [ { id: "subject", accessorKey: "subject", header: ({ column }) => ( <DataTableColumnHeader column={column} label="Subject" /> ), cell: ({ row }) => ( <span className="font-mono text-xs block max-w-xs truncate" title={row.original.subject ?? ""} > {row.original.subject ?? "--"} </span> ), enableColumnFilter: true, enableSorting: false, meta: { label: "Subject", placeholder: "Filter by subject...", variant: "text", }, }, { id: "severity", accessorKey: "severity", header: ({ column }) => ( <DataTableColumnHeader column={column} label="Severity" /> ), cell: ({ row }) => severityBadge(row.original.severity), enableColumnFilter: true, enableSorting: false, enableHiding: false, meta: { label: "Severity", variant: "select", options: [ { label: "Info", value: "info" }, { label: "Warn", value: "warn" }, { label: "Error", value: "error" }, ], }, }, { id: "event_type", accessorKey: "event_type", header: ({ column }) => ( <DataTableColumnHeader column={column} label="Event Type" /> ), cell: ({ row }) => ( <span className="font-mono text-sm">{row.original.event_type}</span> ), enableColumnFilter: true, enableSorting: false, meta: { label: "Category", variant: "select", options: [ { label: "Lexicon", value: "lexicon" }, { label: "Record", value: "record" }, { label: "Script", value: "script" }, { label: "Admin", value: "admin" }, { label: "Backfill", value: "backfill" }, { label: "Plugin", value: "plugin" }, ], }, }, { id: "actor_did", accessorKey: "actor_did", header: ({ column }) => ( <DataTableColumnHeader column={column} label="Actor" /> ), cell: ({ row }) => ( <span className="font-mono text-xs block max-w-[200px] truncate" title={row.original.actor_did ?? "System"} > {row.original.actor_did ?? "System"} </span> ), enableSorting: false, }, { id: "created_at", accessorKey: "created_at", header: ({ column }) => ( <DataTableColumnHeader column={column} label="Time" /> ), cell: ({ row }) => ( <span className="text-muted-foreground whitespace-nowrap text-sm tabular-nums" title={new Date(row.original.created_at).toLocaleString()} > {timeAgo(row.original.created_at)} </span> ), enableSorting: false, }, ], [], );
const table = useReactTable({ data: events, columns, state: { columnFilters, columnVisibility }, defaultColumn: { enableColumnFilter: false, }, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, });
return ( <> <SiteHeader title="Event Logs" /> <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> {error && <p className="text-destructive text-sm">{error}</p>}
<DataTable table={table} showPagination={false} onRowClick={setViewEvent} > <DataTableToolbar table={table} /> </DataTable>
<div className="flex w-full items-center justify-between gap-4 overflow-auto p-1"> <p className="text-muted-foreground flex-1 whitespace-nowrap text-sm"> {events.length} event(s) on this page. </p> <div className="flex items-center space-x-2"> <Button aria-label="Go to previous page" title="Previous page" variant="outline" size="icon" className="size-8" disabled={cursorStack.length === 0 || loading} onClick={handlePrevious} > <ChevronLeft /> </Button> <Button aria-label="Go to next page" title="Next page" variant="outline" size="icon" className="size-8" disabled={!nextCursor || loading} onClick={handleNext} > <ChevronRight /> </Button> </div> </div>
{viewEvent && ( <ResponsiveDialog open onOpenChange={() => setViewEvent(null)}> <ResponsiveDialogContent className="sm:max-w-4xl"> <ResponsiveDialogHeader> <ResponsiveDialogTitle className="flex items-center gap-2"> {severityBadge(viewEvent.severity)} <span className="font-mono text-sm"> {viewEvent.event_type} </span> </ResponsiveDialogTitle> </ResponsiveDialogHeader> <EventDetailBody event={viewEvent} /> </ResponsiveDialogContent> </ResponsiveDialog> )} </div> </> );}