158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
import Link from 'next/link';
|
||
|
|
import { useParams } from 'next/navigation';
|
||
|
|
import { useQuery } from '@tanstack/react-query';
|
||
|
|
import { format, formatDistanceToNow } from 'date-fns';
|
||
|
|
import { AlertTriangle, BookOpen, Search, Wrench } from 'lucide-react';
|
||
|
|
|
||
|
|
import type { Route } from 'next';
|
||
|
|
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
||
|
|
import { PageHeader } from '@/components/shared/page-header';
|
||
|
|
import { EmptyState } from '@/components/shared/empty-state';
|
||
|
|
import { apiFetch } from '@/lib/api/client';
|
||
|
|
import { classifyError } from '@/lib/error-classifier';
|
||
|
|
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||
|
|
|
||
|
|
interface ListResponse {
|
||
|
|
data: ErrorEvent[];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Super-admin error inspector.
|
||
|
|
*
|
||
|
|
* Shows the most recent captured 5xx errors with: when, where (HTTP
|
||
|
|
* method + path), what (error name + message), and a heuristic
|
||
|
|
* "likely culprit" badge driven by `classifyError`. Click into any
|
||
|
|
* row for the full stack + body excerpt + raw metadata.
|
||
|
|
*/
|
||
|
|
export default function AdminErrorsPage() {
|
||
|
|
const params = useParams<{ portSlug: string }>();
|
||
|
|
const portSlug = params?.portSlug ?? '';
|
||
|
|
const [statusFilter, setStatusFilter] = useState('');
|
||
|
|
|
||
|
|
const query = useQuery<ListResponse>({
|
||
|
|
queryKey: ['admin', 'error-events', { statusFilter }],
|
||
|
|
queryFn: () => {
|
||
|
|
const search = new URLSearchParams();
|
||
|
|
if (statusFilter) search.set('statusCode', statusFilter);
|
||
|
|
return apiFetch<ListResponse>(
|
||
|
|
`/api/v1/admin/error-events${search.toString() ? `?${search.toString()}` : ''}`,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const events = query.data?.data ?? [];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<PageHeader
|
||
|
|
title="Error inspector"
|
||
|
|
description="Captured 5xx errors. Click any row for the full stack, request body excerpt, and likely culprit."
|
||
|
|
actions={
|
||
|
|
<Button variant="outline" size="sm" asChild>
|
||
|
|
<Link href={`/${portSlug}/admin/errors/codes` as Route}>
|
||
|
|
<BookOpen className="mr-1.5 h-4 w-4" />
|
||
|
|
Code reference
|
||
|
|
</Link>
|
||
|
|
</Button>
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||
|
|
<Search className="h-4 w-4" /> Filters
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="flex flex-wrap items-end gap-3">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<label className="text-xs text-muted-foreground" htmlFor="status">
|
||
|
|
Status code
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id="status"
|
||
|
|
placeholder="e.g. 500"
|
||
|
|
value={statusFilter}
|
||
|
|
onChange={(e) => setStatusFilter(e.target.value.replace(/\D/g, ''))}
|
||
|
|
className="h-8 w-32"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
{statusFilter && (
|
||
|
|
<Button variant="ghost" size="sm" className="h-8" onClick={() => setStatusFilter('')}>
|
||
|
|
Clear
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{query.isLoading ? (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
||
|
|
<Skeleton key={i} className="h-14 w-full" />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : events.length === 0 ? (
|
||
|
|
<EmptyState
|
||
|
|
icon={AlertTriangle}
|
||
|
|
title="No captured errors"
|
||
|
|
description="Nothing has hit a 5xx in the selected window. That's a good thing."
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div className="rounded-lg border divide-y">
|
||
|
|
{events.map((event) => {
|
||
|
|
const culprit = classifyError(event);
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
key={event.requestId}
|
||
|
|
href={`/${portSlug}/admin/errors/${event.requestId}` as Route}
|
||
|
|
className="flex items-start gap-3 p-3 hover:bg-muted/40"
|
||
|
|
>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Badge
|
||
|
|
variant="outline"
|
||
|
|
className={
|
||
|
|
event.statusCode >= 500
|
||
|
|
? 'border-destructive/40 text-destructive'
|
||
|
|
: 'border-amber-300 text-amber-800'
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{event.statusCode}
|
||
|
|
</Badge>
|
||
|
|
<span className="text-xs font-mono uppercase text-muted-foreground">
|
||
|
|
{event.method}
|
||
|
|
</span>
|
||
|
|
<span className="text-sm font-medium truncate">{event.path}</span>
|
||
|
|
{culprit && (
|
||
|
|
<Badge variant="secondary" className="gap-1 text-xs">
|
||
|
|
<Wrench className="h-3 w-3" />
|
||
|
|
{culprit.label}
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||
|
|
{event.errorName ? `${event.errorName}: ` : ''}
|
||
|
|
{event.errorMessage ?? '(no message)'}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||
|
|
{formatDistanceToNow(new Date(event.createdAt), { addSuffix: true })} ·{' '}
|
||
|
|
{format(new Date(event.createdAt), 'MMM d HH:mm:ss')} · ID{' '}
|
||
|
|
<code className="font-mono">{event.requestId.slice(0, 12)}…</code>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|