Files
HartOMat/frontend/src/pages/WorkerActivity.tsx
T
Hartmut d138bc4bc4 feat(activity): merge CAD + render jobs into unified timeline
- Single chronological list sorted by updated_at (newest first)
- Type badges distinguish CAD processing (FileCode2) from Render jobs (Image)
- Render job rows now link directly to the order (/orders/:id)
- Remove separate "Render Jobs" and "CAD File Processing" sections
- Stat cards simplified to 4: CAD active/failed + Render active/failed
- Backend: add order_id to RenderJobEntry response
- Frontend: add order_id to RenderJobEntry interface, remove flamenco_job_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 20:01:03 +01:00

647 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
Trash2, Ban, ListOrdered, FileCode2,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import {
getWorkerActivity, reprocessCadFile, CadActivityEntry, RenderLog, RenderJobEntry,
getQueueStatus, purgeQueue, cancelTask, QueueTask,
} from '../api/worker'
import LiveRenderLog from '../components/LiveRenderLog'
type UnifiedEvent =
| { kind: 'cad'; ts: number; entry: CadActivityEntry }
| { kind: 'render'; ts: number; job: RenderJobEntry }
export default function WorkerActivityPage() {
const qc = useQueryClient()
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const { data, isLoading, dataUpdatedAt } = useQuery({
queryKey: ['worker-activity'],
queryFn: getWorkerActivity,
refetchInterval: 5000,
})
const reprocessMut = useMutation({
mutationFn: reprocessCadFile,
onSuccess: () => {
toast.success('Re-queued for full reprocessing')
qc.invalidateQueries({ queryKey: ['worker-activity'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const lastUpdated = dataUpdatedAt
? new Date(dataUpdatedAt).toLocaleTimeString('de-DE')
: '—'
const toggle = (id: string) =>
setExpanded((s) => {
const n = new Set(s)
n.has(id) ? n.delete(id) : n.add(id)
return n
})
// Merge + sort both event streams into one unified timeline
const events: UnifiedEvent[] = []
if (data) {
for (const entry of data.cad_processing) {
events.push({ kind: 'cad', ts: new Date(entry.updated_at).getTime(), entry })
}
for (const job of data.render_jobs) {
events.push({ kind: 'render', ts: new Date(job.updated_at).getTime(), job })
}
events.sort((a, b) => b.ts - a.ts)
}
const isEmpty = !isLoading && events.length === 0
return (
<div className="p-8 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Activity size={22} className="text-accent" />
<h1 className="text-2xl font-bold text-content">Worker Activity</h1>
<span className="ml-auto flex items-center gap-2 text-xs text-content-muted">
Auto-refresh every 5 s · Last: {lastUpdated}
<button
onClick={() => qc.invalidateQueries({ queryKey: ['worker-activity'] })}
className="p-1 rounded hover:bg-surface-muted"
title="Refresh now"
>
<RefreshCw size={13} />
</button>
</span>
</div>
{/* Queue panel */}
<QueuePanel />
{/* Summary */}
{data && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="CAD active" value={data.active_count}
color={data.active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
<StatCard label="CAD failed" value={data.failed_count}
color={data.failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} />
<StatCard label="Rendering" value={data.render_active_count}
color={data.render_active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
<StatCard label="Render failed" value={data.render_failed_count}
color={data.render_failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} />
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-content-muted py-12 justify-center">
<Loader2 size={18} className="animate-spin" /> Loading activity
</div>
)}
{isEmpty && (
<div className="card p-12 text-center text-content-muted">
<Activity size={32} className="mx-auto mb-3 text-content-muted" />
<p className="font-medium">No recent activity</p>
<p className="text-sm mt-1">STEP processing and render jobs will appear here.</p>
</div>
)}
{/* ── Unified timeline ─────────────────────────────────────────────── */}
{events.length > 0 && (
<div className="card overflow-hidden divide-y divide-border-light">
{events.map((ev) =>
ev.kind === 'render' ? (
<RenderJobRow key={`render-${ev.job.order_line_id}`} job={ev.job} />
) : (
<CadFileRow
key={`cad-${ev.entry.cad_file_id}`}
entry={ev.entry}
expanded={expanded.has(ev.entry.cad_file_id)}
onToggle={() => toggle(ev.entry.cad_file_id)}
onReprocess={() => reprocessMut.mutate(ev.entry.cad_file_id)}
reprocessPending={reprocessMut.isPending}
/>
)
)}
</div>
)}
</div>
)
}
// ── CAD file row (extracted from inline JSX above) ────────────────────────────
function CadFileRow({
entry, expanded, onToggle, onReprocess, reprocessPending,
}: {
entry: CadActivityEntry
expanded: boolean
onToggle: () => void
onReprocess: () => void
reprocessPending: boolean
}) {
return (
<div>
<div
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none"
onClick={onToggle}
>
{/* Type badge */}
<span className="shrink-0 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-content-muted w-14">
<FileCode2 size={11} />CAD
</span>
{/* Expand toggle */}
<span className="text-content-muted shrink-0">
{expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</span>
<StatusIcon status={entry.processing_status} />
<div className="flex-1 min-w-0">
<p className="font-mono text-sm text-content truncate" title={entry.original_name}>
{entry.original_name}
</p>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{entry.order_numbers.length > 0 && (
<div className="flex gap-1 flex-wrap">
{entry.order_numbers.map((n) => (
<span key={n} className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded">
{n}
</span>
))}
</div>
)}
{entry.file_size != null && (
<span className="text-xs text-content-muted">{formatBytes(entry.file_size)}</span>
)}
{entry.render_log?.renderer && <RendererBadge log={entry.render_log} />}
{entry.render_log?.total_duration_s != null && (
<span className="text-xs text-content-muted flex items-center gap-1">
<Clock size={11} />{entry.render_log.total_duration_s}s
</span>
)}
</div>
{entry.error_message && (
<p className="text-xs text-red-500 mt-0.5 truncate" title={entry.error_message}>
{entry.error_message}
</p>
)}
</div>
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
<p>{new Date(entry.updated_at).toLocaleDateString('de-DE')}</p>
<p>{new Date(entry.updated_at).toLocaleTimeString('de-DE')}</p>
</div>
<button
onClick={(e) => { e.stopPropagation(); onReprocess() }}
disabled={reprocessPending}
title="Re-convert STEP + regenerate thumbnail"
className="shrink-0 p-1.5 rounded text-content-muted hover:text-accent hover:bg-surface-hover transition-colors"
>
<RotateCcw size={14} />
</button>
</div>
{expanded && (
<div className="bg-surface-alt border-t border-border-light px-6 py-4 space-y-4">
<RenderDetails entry={entry} />
</div>
)}
</div>
)
}
// ── Queue panel ──────────────────────────────────────────────────────────────
function shortName(taskName: string): string {
// "app.tasks.step_tasks.regenerate_thumbnail" → "regenerate_thumbnail"
const parts = taskName.split('.')
return parts[parts.length - 1] ?? taskName
}
function firstArg(task: QueueTask): string {
const a = task.args?.[0]
if (!a) return '—'
const s = String(a)
return s.length > 28 ? s.slice(0, 12) + '…' + s.slice(-8) : s
}
function QueuePanel() {
const qc = useQueryClient()
const { data: queue, isLoading } = useQuery({
queryKey: ['worker-queue'],
queryFn: getQueueStatus,
refetchInterval: 3000,
})
const purgeMut = useMutation({
mutationFn: purgeQueue,
onSuccess: (res) => {
toast.success(res.message)
qc.invalidateQueries({ queryKey: ['worker-queue'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Purge failed'),
})
const cancelMut = useMutation({
mutationFn: cancelTask,
onSuccess: () => {
toast.success('Task revoked')
qc.invalidateQueries({ queryKey: ['worker-queue'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
})
const totalPending = queue?.pending_count ?? 0
const activeCount = queue?.active.length ?? 0
const reservedCount = queue?.reserved.length ?? 0
const isEmpty = totalPending === 0 && activeCount === 0 && reservedCount === 0
// Group pending by task name for summary
const pendingGroups: Record<string, number> = {}
for (const t of queue?.pending ?? []) {
const name = shortName(t.task_name)
pendingGroups[name] = (pendingGroups[name] ?? 0) + 1
}
return (
<div className="card overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border-light">
<ListOrdered size={15} className="text-content-muted" />
<h2 className="text-sm font-semibold text-content-secondary flex-1">Celery Queue</h2>
<button
onClick={() => qc.invalidateQueries({ queryKey: ['worker-queue'] })}
className="p-1 rounded hover:bg-surface-muted text-content-muted"
title="Refresh"
>
<RefreshCw size={13} className={isLoading ? 'animate-spin' : ''} />
</button>
{totalPending > 0 && (
<button
onClick={() => {
if (confirm(`Purge all ${totalPending} pending task(s) from the queue?`)) {
purgeMut.mutate()
}
}}
disabled={purgeMut.isPending}
className="flex items-center gap-1 px-2.5 py-1 rounded border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors"
>
<Trash2 size={12} />
Purge all ({totalPending})
</button>
)}
</div>
{/* Body */}
<div className="px-4 py-3 space-y-3">
{/* Summary chips */}
<div className="flex items-center gap-3 text-xs flex-wrap">
<span className={`font-semibold ${totalPending > 0 ? 'text-status-warning-text' : 'text-content-muted'}`}>
{totalPending} pending
</span>
<span className="text-content-muted">·</span>
<span className={`font-semibold ${activeCount > 0 ? 'text-status-info-text' : 'text-content-muted'}`}>
{activeCount} active
</span>
<span className="text-content-muted">·</span>
<span className="text-content-muted">{reservedCount} reserved</span>
{queue?.queue_depths && Object.entries(queue.queue_depths).map(([q, n]) => n > 0 && (
<span key={q} className="ml-1 px-1.5 py-0.5 rounded bg-status-warning-bg border border-border-default text-status-warning-text font-mono">
{q}: {n}
</span>
))}
</div>
{isEmpty && !isLoading && (
<p className="text-xs text-content-muted py-1">Queue is empty no pending or active tasks.</p>
)}
{/* Active tasks */}
{(queue?.active.length ?? 0) > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Active</p>
<div className="space-y-1">
{queue!.active.map((t) => (
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-status-info-bg border border-border-default px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse shrink-0" />
<span className="font-medium text-status-info-text shrink-0">{shortName(t.task_name)}</span>
<span className="text-status-info-text font-mono truncate flex-1">{firstArg(t)}</span>
{t.worker && (
<span className="text-status-info-text truncate max-w-[120px]">{t.worker.split('@')[0]}</span>
)}
<button
onClick={() => cancelMut.mutate(t.task_id)}
disabled={cancelMut.isPending}
title="Cancel (revoke + terminate)"
className="shrink-0 p-0.5 rounded text-status-info-text hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Ban size={13} />
</button>
</div>
))}
</div>
</div>
)}
{/* Reserved tasks */}
{(queue?.reserved.length ?? 0) > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Reserved (prefetched)</p>
<div className="space-y-1">
{queue!.reserved.map((t) => (
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-surface-alt border border-border-default px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-gray-400 shrink-0" />
<span className="font-medium text-content-secondary shrink-0">{shortName(t.task_name)}</span>
<span className="text-content-secondary font-mono truncate flex-1">{firstArg(t)}</span>
<button
onClick={() => cancelMut.mutate(t.task_id)}
disabled={cancelMut.isPending}
title="Cancel (revoke)"
className="shrink-0 p-0.5 rounded text-content-muted hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Ban size={13} />
</button>
</div>
))}
</div>
</div>
)}
{/* Pending breakdown (grouped by task name) */}
{totalPending > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">
Pending ({totalPending}{totalPending > 100 ? ', showing first 100' : ''})
</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(pendingGroups).map(([name, count]) => (
<span
key={name}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-default bg-status-warning-bg text-status-warning-text text-xs font-medium"
>
{name}
<span className="bg-status-warning-bg text-status-warning-text rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none border border-border-default">
{count}
</span>
</span>
))}
</div>
</div>
)}
</div>
</div>
)
}
// ── Render job row ───────────────────────────────────────────────────────────
function RenderJobRow({ job }: { job: RenderJobEntry }) {
const elapsed = job.render_started_at && job.render_completed_at
? ((new Date(job.render_completed_at).getTime() - new Date(job.render_started_at).getTime()) / 1000).toFixed(1)
: null
return (
<>
<div className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover">
{/* Type badge */}
<span className="shrink-0 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-violet-500 w-14">
<Image size={11} />Render
</span>
<StatusIcon status={job.render_status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-content truncate">
{job.product_name || 'Unknown product'}
</span>
{job.output_type_name && (
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-light text-accent font-medium">
{job.output_type_name}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{job.order_number && job.order_id ? (
<Link
to={`/orders/${job.order_id}`}
className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded hover:bg-surface-hover"
>
{job.order_number}
</Link>
) : job.order_number ? (
<span className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded">
{job.order_number}
</span>
) : null}
{job.render_backend_used && (
<span className="text-xs px-1.5 py-0.5 rounded font-medium bg-status-info-bg text-status-info-text">
Celery
</span>
)}
{elapsed && (
<span className="text-xs text-content-muted flex items-center gap-1">
<Clock size={11} />{elapsed}s
</span>
)}
</div>
</div>
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
<p>{new Date(job.updated_at).toLocaleDateString('de-DE')}</p>
<p>{new Date(job.updated_at).toLocaleTimeString('de-DE')}</p>
</div>
</div>
<div className="px-4 pb-1">
<LiveRenderLog
orderLineId={job.order_line_id}
isActive={job.render_status === 'processing'}
/>
</div>
</>
)
}
// ── Render detail panel ──────────────────────────────────────────────────────
function RenderDetails({ entry }: { entry: CadActivityEntry }) {
const log = entry.render_log
return (
<div className="space-y-4">
{/* File info */}
<Section icon={<Image size={13} />} title="File">
<KVGrid>
<KV label="Name" value={entry.original_name} mono />
<KV label="Size" value={entry.file_size != null ? formatBytes(entry.file_size) : '—'} />
<KV label="Status" value={entry.processing_status} />
<KV label="Uploaded" value={new Date(entry.created_at).toLocaleString('de-DE')} />
<KV label="Last updated" value={new Date(entry.updated_at).toLocaleString('de-DE')} />
{entry.order_numbers.length > 0 && (
<KV label="Orders" value={entry.order_numbers.join(', ')} />
)}
</KVGrid>
{entry.error_message && (
<div className="mt-2 rounded bg-red-50 border border-red-200 px-3 py-2">
<p className="text-xs font-semibold text-red-600 mb-0.5">Error</p>
<pre className="text-xs text-red-700 whitespace-pre-wrap break-words">{entry.error_message}</pre>
</div>
)}
</Section>
{/* Render settings */}
{log && (
<Section icon={<Cpu size={13} />} title="Render settings">
<KVGrid>
<KV label="Renderer" value={log.renderer ?? '—'} />
{log.renderer === 'blender' && <>
<KV label="Engine" value={log.engine_used ?? log.engine ?? '—'} highlight={log.engine_used !== log.engine} />
<KV label="Samples" value={log.samples?.toString() ?? '—'} />
<KV label="Device" value={log.cycles_device ?? '—'} />
<KV label="STL quality" value={log.stl_quality ?? '—'} />
<KV label="Smooth angle" value={log.smooth_angle != null ? `${log.smooth_angle}°` : '—'} />
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
</>}
{log.renderer === 'threejs' && (
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
)}
<KV label="Output format" value={log.format ?? '—'} />
{log.fallback && <KV label="Fallback" value="Yes (Pillow placeholder)" highlight />}
</KVGrid>
</Section>
)}
{/* Timing */}
{log && (log.total_duration_s != null || log.stl_duration_s != null) && (
<Section icon={<Clock size={13} />} title="Timing">
<KVGrid>
{log.total_duration_s != null && <KV label="Total" value={`${log.total_duration_s}s`} />}
{log.stl_duration_s != null && <KV label="STEP→STL" value={`${log.stl_duration_s}s`} />}
{log.render_duration_s != null && <KV label="Render" value={`${log.render_duration_s}s`} />}
{log.stl_size_bytes != null && <KV label="STL size" value={formatBytes(log.stl_size_bytes)} />}
{log.output_size_bytes != null && <KV label="PNG size" value={formatBytes(log.output_size_bytes)} />}
{log.parts_count != null && <KV label="Mesh parts" value={log.parts_count.toString()} />}
</KVGrid>
</Section>
)}
{/* Blender log */}
{log?.log_lines && log.log_lines.length > 0 && (
<Section icon={<Terminal size={13} />} title={`Blender log (${log.log_lines.length} lines)`}>
<BlenderLog lines={log.log_lines} />
</Section>
)}
</div>
)
}
function Section({
icon, title, children,
}: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
return (
<div>
<p className="flex items-center gap-1.5 text-xs font-semibold text-content-muted uppercase tracking-wide mb-2">
{icon}{title}
</p>
{children}
</div>
)
}
function KVGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-1.5">
{children}
</div>
)
}
function KV({ label, value, mono, highlight }: {
label: string; value: string; mono?: boolean; highlight?: boolean
}) {
return (
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-wide text-content-muted">{label}</span>
<span className={`text-xs break-all ${mono ? 'font-mono' : ''} ${highlight ? 'text-status-warning-text font-medium' : 'text-content-secondary'}`}>
{value}
</span>
</div>
)
}
function BlenderLog({ lines }: { lines: string[] }) {
return (
<div className="bg-gray-900 rounded-md overflow-auto max-h-64">
<pre className="text-xs text-gray-200 p-3 leading-5 whitespace-pre-wrap">
{lines.map((l, i) => {
const color =
l.includes('ERROR') || l.includes('failed') ? 'text-red-400' :
l.includes('WARNING') || l.includes('warn') ? 'text-yellow-300' :
l.includes('Saved:') || l.includes('render done') ? 'text-green-400' :
l.includes('separated into') || l.includes('parts_count') ? 'text-cyan-400' :
'text-gray-200'
return (
<span key={i} className={`block ${color}`}>{l}</span>
)
})}
</pre>
</div>
)
}
function RendererBadge({ log }: { log: RenderLog }) {
if (log.renderer === 'blender') {
const eng = log.engine_used ?? log.engine ?? ''
const label = eng.includes('fallback')
? `Blender · Cycles (↩ fallback)`
: `Blender · ${eng}`
return (
<span className="text-xs bg-status-info-bg text-status-info-text px-1.5 py-0.5 rounded font-medium">
{label}
</span>
)
}
if (log.renderer === 'threejs') {
return (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">
Three.js
</span>
)
}
return (
<span className="text-xs bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded font-medium">
{log.renderer}
</span>
)
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 size={16} className="text-green-500 shrink-0" />
if (status === 'failed') return <XCircle size={16} className="text-red-500 shrink-0" />
if (status === 'processing') return <Loader2 size={16} className="animate-spin text-blue-500 shrink-0" />
return <Clock size={16} className="text-content-muted shrink-0" />
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="card p-4 text-center">
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-content-muted mt-0.5">{label}</p>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}