"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 (
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()}
)}
{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
>
);
}