Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
src/components/admin/queue-detail-table.tsx
Normal file
211
src/components/admin/queue-detail-table.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Trash2, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { QueueJobSummary, PaginatedQueueJobs } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
|
||||
|
||||
interface QueueDetailTableProps {
|
||||
queueName: string;
|
||||
}
|
||||
|
||||
const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
waiting: 'outline',
|
||||
active: 'default',
|
||||
completed: 'secondary',
|
||||
failed: 'destructive',
|
||||
delayed: 'outline',
|
||||
};
|
||||
|
||||
function formatDate(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function truncateId(id: string): string {
|
||||
return id.length > 12 ? `${id.slice(0, 8)}...` : id;
|
||||
}
|
||||
|
||||
function truncateReason(reason: string | undefined): string {
|
||||
if (!reason) return '—';
|
||||
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
||||
}
|
||||
|
||||
export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<JobStatus>('failed');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 20;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['queue', 'jobs', queueName, status, page],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: PaginatedQueueJobs }>(
|
||||
`/api/v1/admin/queues/${queueName}?status=${status}&page=${page}&limit=${limit}`,
|
||||
).then((r) => r.data),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (jobId: string) =>
|
||||
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}/retry`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (jobId: string) =>
|
||||
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
|
||||
},
|
||||
});
|
||||
|
||||
const jobs: QueueJobSummary[] = data?.jobs ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
function handleStatusChange(value: string) {
|
||||
setStatus(value as JobStatus);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={status} onValueChange={handleStatusChange}>
|
||||
<TabsList>
|
||||
{(['waiting', 'active', 'completed', 'failed', 'delayed'] as JobStatus[]).map((s) => (
|
||||
<TabsTrigger key={s} value={s} className="capitalize">
|
||||
{s}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Processed</TableHead>
|
||||
<TableHead className="max-w-[240px]">Failed Reason</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
Loading jobs...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
No {status} jobs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell className="font-mono text-xs" title={job.id}>
|
||||
{truncateId(job.id)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{job.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[status]}>{status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(job.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(job.processedOn)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-xs text-muted-foreground max-w-[240px] truncate"
|
||||
title={job.failedReason}
|
||||
>
|
||||
{truncateReason(job.failedReason)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{status === 'failed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="Retry job"
|
||||
disabled={retryMutation.isPending}
|
||||
onClick={() => retryMutation.mutate(job.id)}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
title="Delete job"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(job.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{total} total jobs — page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user