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>
This commit is contained in:
2026-03-06 20:01:03 +01:00
parent ab3f9c734a
commit d138bc4bc4
3 changed files with 135 additions and 120 deletions
+132 -119
View File
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
ExternalLink, Trash2, Ban, ListOrdered,
Trash2, Ban, ListOrdered, FileCode2,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import {
@@ -13,6 +13,10 @@ import {
} 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())
@@ -43,6 +47,20 @@ export default function WorkerActivityPage() {
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">
@@ -65,13 +83,11 @@ export default function WorkerActivityPage() {
{/* Summary */}
{data && (
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4">
<StatCard label="CAD files" value={data.cad_processing.length} color="text-content-secondary" />
<StatCard label="CAD processing" value={data.active_count}
<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="Render jobs" value={data.render_jobs.length} color="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}
@@ -85,113 +101,115 @@ export default function WorkerActivityPage() {
</div>
)}
{data && data.cad_processing.length === 0 && !isLoading && (
{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 file processing jobs will appear here.</p>
<p className="text-sm mt-1">STEP processing and render jobs will appear here.</p>
</div>
)}
{/* ── Render Jobs ─────────────────────────────────────────────────── */}
{data && data.render_jobs.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-content-muted uppercase tracking-wide mb-2">Render Jobs</h2>
<div className="card overflow-hidden divide-y divide-border-light">
{data.render_jobs.map((job) => (
<RenderJobRow key={job.order_line_id} job={job} />
))}
</div>
</div>
)}
{/* ── CAD File Processing ──────────────────────────────────────────── */}
{data && data.cad_processing.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-content-muted uppercase tracking-wide mb-2">CAD File Processing</h2>
</div>
)}
{data && data.cad_processing.length > 0 && (
{/* ── Unified timeline ─────────────────────────────────────────────── */}
{events.length > 0 && (
<div className="card overflow-hidden divide-y divide-border-light">
{data.cad_processing.map((entry) => (
<div key={entry.cad_file_id}>
{/* ── Main row ── */}
<div
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none"
onClick={() => toggle(entry.cad_file_id)}
>
{/* Expand toggle */}
<span className="text-content-muted shrink-0">
{expanded.has(entry.cad_file_id)
? <ChevronDown size={15} />
: <ChevronRight size={15} />}
</span>
{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>
)
}
{/* Status icon */}
<StatusIcon status={entry.processing_status} />
// ── CAD file row (extracted from inline JSX above) ────────────────────────────
{/* File name */}
<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 total
</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>
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>
{/* Timestamp */}
<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>
{/* Expand toggle */}
<span className="text-content-muted shrink-0">
{expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</span>
{/* Re-process button */}
<button
onClick={(e) => {
e.stopPropagation()
reprocessMut.mutate(entry.cad_file_id)
}}
disabled={reprocessMut.isPending}
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>
<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>
{/* ── Expanded detail panel ── */}
{expanded.has(entry.cad_file_id) && (
<div className="bg-surface-alt border-t border-border-light px-6 py-4 space-y-4">
<RenderDetails entry={entry} />
</div>
)}
</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>
@@ -393,6 +411,11 @@ function RenderJobRow({ job }: { job: RenderJobEntry }) {
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">
@@ -407,32 +430,22 @@ function RenderJobRow({ job }: { job: RenderJobEntry }) {
)}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{job.order_number && (
{job.order_number && job.order_id ? (
<Link
to={`/orders`}
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.render_backend_used && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
job.render_backend_used === 'flamenco'
? 'bg-status-warning-bg text-status-warning-text'
: 'bg-status-info-bg text-status-info-text'
}`}>
{job.render_backend_used === 'flamenco' ? 'Flamenco' : 'Celery'}
) : 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>
)}
{job.flamenco_job_id && (
<a
href="http://localhost:8080"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-status-warning-text hover:text-status-warning-text flex items-center gap-0.5"
>
<ExternalLink size={10} /> Flamenco
</a>
)}
{elapsed && (
<span className="text-xs text-content-muted flex items-center gap-1">