208eb21988
- QueueStatusWidget + WorkerStatusWidget expected res.data to be ActivityEntry[]
but /api/worker/activity returns {cad_processing: [...], render_jobs: [...]}
→ TypeError: entries.filter is not a function → blank screen (no error boundary)
- Both widgets now use ActivityResponse interface and read data?.cad_processing
- Field names updated: id→cad_file_id, filename→original_name, status→processing_status
- AdminDashboard: fix duplicate React key in top_products table (pim_id can repeat)
→ use index suffix to guarantee unique keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
106 lines
3.1 KiB
TypeScript
106 lines
3.1 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { Activity } from 'lucide-react'
|
|
import api from '../../../api/client'
|
|
|
|
interface ActivityEntry {
|
|
cad_file_id: string
|
|
original_name: string
|
|
processing_status: string
|
|
created_at: string
|
|
}
|
|
|
|
interface ActivityResponse {
|
|
cad_processing: ActivityEntry[]
|
|
render_jobs: ActivityEntry[]
|
|
}
|
|
|
|
function Skeleton() {
|
|
return (
|
|
<div className="animate-pulse space-y-2">
|
|
{[0, 1, 2].map((i) => (
|
|
<div key={i} className="h-8 rounded bg-surface-muted" />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function QueueStatusWidget() {
|
|
const { data, isLoading, error } = useQuery<ActivityResponse>({
|
|
queryKey: ['worker-activity-widget'],
|
|
queryFn: async () => {
|
|
const res = await api.get('/worker/activity')
|
|
return res.data as ActivityResponse
|
|
},
|
|
refetchInterval: 15_000,
|
|
staleTime: 10_000,
|
|
retry: 1,
|
|
})
|
|
|
|
if (isLoading) return <Skeleton />
|
|
if (error) {
|
|
return <p className="text-xs text-red-500">Failed to load queue status</p>
|
|
}
|
|
|
|
const entries = data?.cad_processing ?? []
|
|
const processing = entries.filter((e) => e.processing_status === 'processing').length
|
|
const failed = entries.filter((e) => e.processing_status === 'failed').length
|
|
const recent = entries.slice(0, 5)
|
|
|
|
const statusDot = processing > 0
|
|
? 'bg-blue-500'
|
|
: failed > 0
|
|
? 'bg-red-500'
|
|
: 'bg-green-500'
|
|
|
|
const statusLabel = processing > 0
|
|
? `${processing} processing`
|
|
: failed > 0
|
|
? `${failed} failed`
|
|
: 'Idle'
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Summary row */}
|
|
<div className="flex items-center gap-2">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ${statusDot}`} />
|
|
<span className="text-sm font-medium text-content">{statusLabel}</span>
|
|
<span className="text-xs text-content-muted ml-auto">
|
|
{entries.length} recent tasks
|
|
</span>
|
|
</div>
|
|
|
|
{/* Recent activity */}
|
|
<div className="space-y-1">
|
|
{recent.length === 0 && (
|
|
<p className="text-xs text-content-muted text-center py-2">No recent activity</p>
|
|
)}
|
|
{recent.map((entry) => (
|
|
<div
|
|
key={entry.cad_file_id}
|
|
className="flex items-center gap-2 rounded px-2 py-1.5 text-xs"
|
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
|
>
|
|
<Activity size={12} className="text-content-muted shrink-0" />
|
|
<span className="flex-1 truncate text-content-secondary" title={entry.original_name}>
|
|
{entry.original_name}
|
|
</span>
|
|
<span
|
|
className={`font-medium shrink-0 ${
|
|
entry.processing_status === 'completed'
|
|
? 'text-green-600'
|
|
: entry.processing_status === 'failed'
|
|
? 'text-red-500'
|
|
: entry.processing_status === 'processing'
|
|
? 'text-blue-500'
|
|
: 'text-content-muted'
|
|
}`}
|
|
>
|
|
{entry.processing_status}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|