"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ColumnDef, type ColumnFiltersState, type RowSelectionState, type VisibilityState, getCoreRowModel, useReactTable, } from "@tanstack/react-table"; import { toast } from "sonner"; import { toastError } from "@/lib/format"; import { getDeadLetters, getDeadLetter, retryDeadLetter, reindexDeadLetter, dismissDeadLetter, bulkRetryDeadLetters, bulkReindexDeadLetters, bulkDismissDeadLetters, } from "@/lib/api"; import type { DeadLetterSummary, DeadLetterDetail } from "@/types/dead-letters"; 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 { MonacoEditor } from "@/components/monaco-editor"; import { SiteHeader } from "@/components/site-header"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ChevronLeft, ChevronRight, RotateCcw, RefreshCw, XCircle, MoreHorizontal, } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; function actionBadge(action: string) { switch (action) { case "delete": return delete; case "update": return ( update ); default: return {action}; } } 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`; } function DetailBody({ detail }: { detail: DeadLetterDetail }) { return (
URI

{detail.uri}

DID

{detail.did}

Collection

{detail.collection}

Lexicon

{detail.lexicon_id}

Action

{actionBadge(detail.action)}

Attempts

{detail.attempts}

Created

{new Date(detail.created_at).toLocaleString()}

{detail.resolved_at && (
Resolved

{new Date(detail.resolved_at).toLocaleString()}

)}
Error
{detail.error}
{detail.record && (
Record
)}
); } export default function DeadLettersPage() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [viewDetail, setViewDetail] = useState(null); const [actionLoading, setActionLoading] = useState(false); const [resolvedFilter, setResolvedFilter] = useState("false"); const [rowSelection, setRowSelection] = useState({}); const [dismissAllOpen, setDismissAllOpen] = useState(false); const [cursorStack, setCursorStack] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); const debounceRef = useRef>(null); const [debouncedFilters, setDebouncedFilters] = useState(columnFilters); useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setDebouncedFilters(columnFilters); }, 300); }, [columnFilters]); const collectionFilter = useMemo( () => ( debouncedFilters.find((f) => f.id === "collection")?.value as | string[] | undefined )?.join(",") || undefined, [debouncedFilters], ); const fetchItems = useCallback( async (cursor?: string) => { setLoading(true); try { const data = await getDeadLetters({ collection: collectionFilter, resolved: resolvedFilter, cursor, limit: 50, }); setItems(data.dead_letters); setNextCursor(data.cursor); setRowSelection({}); } catch (e: unknown) { toastError("Failed to load dead letters", e); setItems([]); setNextCursor(null); } finally { setLoading(false); } }, [collectionFilter, resolvedFilter], ); useEffect(() => { setCursorStack([]); fetchItems(); }, [fetchItems]); useEffect(() => { if (Object.keys(rowSelection).length === 0) return; const validIds = new Set(items.map((item) => item.id)); const pruned: RowSelectionState = {}; let changed = false; for (const [id, selected] of Object.entries(rowSelection)) { if (validIds.has(id)) { pruned[id] = selected; } else { changed = true; } } if (changed) setRowSelection(pruned); }, [items]); function handleNext() { if (!nextCursor) return; setCursorStack((prev) => [...prev, nextCursor]); fetchItems(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); fetchItems(prevCursor); } async function openDetail(row: DeadLetterSummary) { try { const detail = await getDeadLetter(row.id); setViewDetail(detail); } catch (e: unknown) { toastError("Failed to load dead letter detail", e); } } async function handleDetailAction(action: "retry" | "reindex" | "dismiss") { if (!viewDetail) return; setActionLoading(true); try { if (action === "retry") await retryDeadLetter(viewDetail.id); else if (action === "reindex") await reindexDeadLetter(viewDetail.id); else await dismissDeadLetter(viewDetail.id); toast.success(action === "retry" ? "Dead letter retried" : action === "reindex" ? "Dead letter re-indexed" : "Dead letter dismissed"); setViewDetail(null); fetchItems(); } catch (e: unknown) { toastError(`Failed to ${action} dead letter`, e); } finally { setActionLoading(false); } } const selectedIds = useMemo( () => Object.keys(rowSelection).filter((k) => rowSelection[k]), [rowSelection], ); async function handleBulkAction( action: "retry" | "reindex" | "dismiss", scope: "selected" | "all", ) { setLoading(true); try { const count = selectedIds.length; const body = scope === "all" ? { all: true, collection: collectionFilter } : { ids: selectedIds }; if (action === "retry") await bulkRetryDeadLetters(body); else if (action === "reindex") await bulkReindexDeadLetters(body); else await bulkDismissDeadLetters(body); const verb = action === "retry" ? "retried" : action === "reindex" ? "re-indexed" : "dismissed"; toast.success(scope === "all" ? `All matching dead letters ${verb}` : `${count} dead ${count === 1 ? "letter" : "letters"} ${verb}`); setRowSelection({}); fetchItems(); } catch (e: unknown) { toastError(`Failed to ${action} dead letters`, e); } finally { setLoading(false); } } const columns = useMemo[]>( () => [ { id: "select", header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value) } aria-label="Select all" /> ), cell: ({ row }) => (
e.stopPropagation()}> row.toggleSelected(!!value)} aria-label="Select row" />
), enableSorting: false, enableColumnFilter: false, size: 32, }, { id: "collection", accessorKey: "collection", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.collection} ), enableColumnFilter: true, enableSorting: false, meta: { label: "Collection", placeholder: "Filter by collection...", variant: "text", }, }, { id: "uri", accessorKey: "uri", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.uri} ), enableSorting: false, }, { id: "action", accessorKey: "action", header: ({ column }) => ( ), cell: ({ row }) => actionBadge(row.original.action), enableSorting: false, }, { id: "error", accessorKey: "error", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.error.split("\n")[0]} ), enableSorting: false, }, { id: "attempts", accessorKey: "attempts", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.attempts} ), enableSorting: false, }, { id: "created_at", accessorKey: "created_at", header: ({ column }) => ( ), cell: ({ row }) => ( {timeAgo(row.original.created_at)} ), enableSorting: false, }, ...(resolvedFilter !== "false" ? [ { id: "status", accessorKey: "resolved_at" as const, header: ({ column }) => ( ), cell: ({ row }) => row.original.resolved_at ? ( resolved ) : ( unresolved ), enableSorting: false, } satisfies ColumnDef, ] : []), ], [resolvedFilter], ); const table = useReactTable({ data: items, columns, state: { columnFilters, columnVisibility, rowSelection }, defaultColumn: { enableColumnFilter: false }, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, }); return ( <>
{selectedIds.length > 0 && (
{selectedIds.length} selected Dismiss {selectedIds.length} dead {selectedIds.length === 1 ? "letter" : "letters"}? Dismissed dead letters will be marked as resolved and will not be retried. Cancel handleBulkAction("dismiss", "selected")}>Dismiss handleBulkAction("retry", "all")} > Retry all matching handleBulkAction("reindex", "all")} > Re-index all matching setDismissAllOpen(true)} > Dismiss all matching
)}

{items.length} item(s) on this page.

{ if (!open) setViewDetail(null); }} > {viewDetail && ( <> {actionBadge(viewDetail.action)} {viewDetail.collection}
{viewDetail.resolved_at == null && (
)} )}
Dismiss all matching dead letters? All dead letters matching the current filters will be marked as resolved. This action cannot be undone. Cancel handleBulkAction("dismiss", "all")}>Dismiss All
); }