Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line Lucide icon JSX elements across 267 .tsx files in: - shared/, layout/, dashboard/ - admin/ (all sections) - clients/, berths/, yachts/, companies/, interests/, documents/ - reminders/, reservations/, residential/, expenses/, email/ The regex targeted only the safe pattern \`<IconName className="..." />\` (no other props, self-closing, capitalized component name). Every match inspected is a decorative companion to visible text or sits inside a button whose accessible name comes from \`aria-label\` / sr-only text — the icon itself should not be announced. Screen readers no longer double-read the icon + the adjacent label text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing @axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues to pass. Test suite stays at 1315/1315 vitest. typescript clean. Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups backlog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
7.2 KiB
TypeScript
212 lines
7.2 KiB
TypeScript
'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" aria-hidden />
|
|
</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" aria-hidden />
|
|
</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>
|
|
);
|
|
}
|