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
+2
View File
@@ -33,6 +33,7 @@ class CadActivityEntry(BaseModel):
class RenderJobEntry(BaseModel): class RenderJobEntry(BaseModel):
order_line_id: str order_line_id: str
order_id: str | None
order_number: str | None order_number: str | None
product_name: str | None product_name: str | None
output_type_name: str | None output_type_name: str | None
@@ -134,6 +135,7 @@ async def get_worker_activity(
for rl in render_lines: for rl in render_lines:
render_entries.append(RenderJobEntry( render_entries.append(RenderJobEntry(
order_line_id=str(rl.id), order_line_id=str(rl.id),
order_id=str(rl.order_id) if rl.order_id else None,
order_number=rl.order.order_number if rl.order else None, order_number=rl.order.order_number if rl.order else None,
product_name=rl.product.name if rl.product else None, product_name=rl.product.name if rl.product else None,
output_type_name=rl.output_type.name if rl.output_type else None, output_type_name=rl.output_type.name if rl.output_type else None,
+1 -1
View File
@@ -37,12 +37,12 @@ export interface CadActivityEntry {
export interface RenderJobEntry { export interface RenderJobEntry {
order_line_id: string order_line_id: string
order_id: string | null
order_number: string | null order_number: string | null
product_name: string | null product_name: string | null
output_type_name: string | null output_type_name: string | null
render_status: 'processing' | 'completed' | 'failed' | string render_status: 'processing' | 'completed' | 'failed' | string
render_backend_used: string | null render_backend_used: string | null
flamenco_job_id: string | null
render_started_at: string | null render_started_at: string | null
render_completed_at: string | null render_completed_at: string | null
updated_at: string updated_at: string
+83 -70
View File
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
import { import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw, Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image, ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
ExternalLink, Trash2, Ban, ListOrdered, Trash2, Ban, ListOrdered, FileCode2,
} from 'lucide-react' } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
@@ -13,6 +13,10 @@ import {
} from '../api/worker' } from '../api/worker'
import LiveRenderLog from '../components/LiveRenderLog' import LiveRenderLog from '../components/LiveRenderLog'
type UnifiedEvent =
| { kind: 'cad'; ts: number; entry: CadActivityEntry }
| { kind: 'render'; ts: number; job: RenderJobEntry }
export default function WorkerActivityPage() { export default function WorkerActivityPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [expanded, setExpanded] = useState<Set<string>>(new Set()) const [expanded, setExpanded] = useState<Set<string>>(new Set())
@@ -43,6 +47,20 @@ export default function WorkerActivityPage() {
return n 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 ( return (
<div className="p-8 max-w-5xl mx-auto space-y-6"> <div className="p-8 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -65,13 +83,11 @@ export default function WorkerActivityPage() {
{/* Summary */} {/* Summary */}
{data && ( {data && (
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="CAD files" value={data.cad_processing.length} color="text-content-secondary" /> <StatCard label="CAD active" value={data.active_count}
<StatCard label="CAD processing" value={data.active_count}
color={data.active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} /> color={data.active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
<StatCard label="CAD failed" value={data.failed_count} <StatCard label="CAD failed" value={data.failed_count}
color={data.failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} /> 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} <StatCard label="Rendering" value={data.render_active_count}
color={data.render_active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} /> color={data.render_active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
<StatCard label="Render failed" value={data.render_failed_count} <StatCard label="Render failed" value={data.render_failed_count}
@@ -85,52 +101,66 @@ export default function WorkerActivityPage() {
</div> </div>
)} )}
{data && data.cad_processing.length === 0 && !isLoading && ( {isEmpty && (
<div className="card p-12 text-center text-content-muted"> <div className="card p-12 text-center text-content-muted">
<Activity size={32} className="mx-auto mb-3 text-content-muted" /> <Activity size={32} className="mx-auto mb-3 text-content-muted" />
<p className="font-medium">No recent activity</p> <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> </div>
)} )}
{/* ── Render Jobs ─────────────────────────────────────────────────── */} {/* ── Unified timeline ─────────────────────────────────────────────── */}
{data && data.render_jobs.length > 0 && ( {events.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"> <div className="card overflow-hidden divide-y divide-border-light">
{data.render_jobs.map((job) => ( {events.map((ev) =>
<RenderJobRow key={job.order_line_id} job={job} /> ev.kind === 'render' ? (
))} <RenderJobRow key={`render-${ev.job.order_line_id}`} job={ev.job} />
</div> ) : (
<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>
)} )}
</div>
)
}
{/* ── CAD File Processing ──────────────────────────────────────────── */} // ── CAD file row (extracted from inline JSX above) ────────────────────────────
{data && data.cad_processing.length > 0 && (
function CadFileRow({
entry, expanded, onToggle, onReprocess, reprocessPending,
}: {
entry: CadActivityEntry
expanded: boolean
onToggle: () => void
onReprocess: () => void
reprocessPending: boolean
}) {
return (
<div> <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 && (
<div className="card overflow-hidden divide-y divide-border-light">
{data.cad_processing.map((entry) => (
<div key={entry.cad_file_id}>
{/* ── Main row ── */}
<div <div
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none" className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none"
onClick={() => toggle(entry.cad_file_id)} onClick={onToggle}
> >
{/* Expand toggle */} {/* Type badge */}
<span className="text-content-muted shrink-0"> <span className="shrink-0 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-content-muted w-14">
{expanded.has(entry.cad_file_id) <FileCode2 size={11} />CAD
? <ChevronDown size={15} /> </span>
: <ChevronRight size={15} />}
{/* Expand toggle */}
<span className="text-content-muted shrink-0">
{expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</span> </span>
{/* Status icon */}
<StatusIcon status={entry.processing_status} /> <StatusIcon status={entry.processing_status} />
{/* File name */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-mono text-sm text-content truncate" title={entry.original_name}> <p className="font-mono text-sm text-content truncate" title={entry.original_name}>
{entry.original_name} {entry.original_name}
@@ -148,12 +178,10 @@ export default function WorkerActivityPage() {
{entry.file_size != null && ( {entry.file_size != null && (
<span className="text-xs text-content-muted">{formatBytes(entry.file_size)}</span> <span className="text-xs text-content-muted">{formatBytes(entry.file_size)}</span>
)} )}
{entry.render_log?.renderer && ( {entry.render_log?.renderer && <RendererBadge log={entry.render_log} />}
<RendererBadge log={entry.render_log} />
)}
{entry.render_log?.total_duration_s != null && ( {entry.render_log?.total_duration_s != null && (
<span className="text-xs text-content-muted flex items-center gap-1"> <span className="text-xs text-content-muted flex items-center gap-1">
<Clock size={11} />{entry.render_log.total_duration_s}s total <Clock size={11} />{entry.render_log.total_duration_s}s
</span> </span>
)} )}
</div> </div>
@@ -164,19 +192,14 @@ export default function WorkerActivityPage() {
)} )}
</div> </div>
{/* Timestamp */}
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block"> <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).toLocaleDateString('de-DE')}</p>
<p>{new Date(entry.updated_at).toLocaleTimeString('de-DE')}</p> <p>{new Date(entry.updated_at).toLocaleTimeString('de-DE')}</p>
</div> </div>
{/* Re-process button */}
<button <button
onClick={(e) => { onClick={(e) => { e.stopPropagation(); onReprocess() }}
e.stopPropagation() disabled={reprocessPending}
reprocessMut.mutate(entry.cad_file_id)
}}
disabled={reprocessMut.isPending}
title="Re-convert STEP + regenerate thumbnail" 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" className="shrink-0 p-1.5 rounded text-content-muted hover:text-accent hover:bg-surface-hover transition-colors"
> >
@@ -184,17 +207,12 @@ export default function WorkerActivityPage() {
</button> </button>
</div> </div>
{/* ── Expanded detail panel ── */} {expanded && (
{expanded.has(entry.cad_file_id) && (
<div className="bg-surface-alt border-t border-border-light px-6 py-4 space-y-4"> <div className="bg-surface-alt border-t border-border-light px-6 py-4 space-y-4">
<RenderDetails entry={entry} /> <RenderDetails entry={entry} />
</div> </div>
)} )}
</div> </div>
))}
</div>
)}
</div>
) )
} }
@@ -393,6 +411,11 @@ function RenderJobRow({ job }: { job: RenderJobEntry }) {
return ( return (
<> <>
<div className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover"> <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} /> <StatusIcon status={job.render_status} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -407,32 +430,22 @@ function RenderJobRow({ job }: { job: RenderJobEntry }) {
)} )}
</div> </div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap"> <div className="flex items-center gap-3 mt-0.5 flex-wrap">
{job.order_number && ( {job.order_number && job.order_id ? (
<Link <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" 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} {job.order_number}
</Link> </Link>
)} ) : job.order_number ? (
{job.render_backend_used && ( <span className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded">
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${ {job.order_number}
job.render_backend_used === 'flamenco' </span>
? 'bg-status-warning-bg text-status-warning-text' ) : null}
: 'bg-status-info-bg text-status-info-text' {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">
{job.render_backend_used === 'flamenco' ? 'Flamenco' : 'Celery'} Celery
</span> </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 && ( {elapsed && (
<span className="text-xs text-content-muted flex items-center gap-1"> <span className="text-xs text-content-muted flex items-center gap-1">