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
+132 -119
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,113 +101,115 @@ 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">
{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 && (
<div className="card overflow-hidden divide-y divide-border-light"> <div className="card overflow-hidden divide-y divide-border-light">
{data.cad_processing.map((entry) => ( {events.map((ev) =>
<div key={entry.cad_file_id}> ev.kind === 'render' ? (
{/* ── Main row ── */} <RenderJobRow key={`render-${ev.job.order_line_id}`} job={ev.job} />
<div ) : (
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none" <CadFileRow
onClick={() => toggle(entry.cad_file_id)} key={`cad-${ev.entry.cad_file_id}`}
> entry={ev.entry}
{/* Expand toggle */} expanded={expanded.has(ev.entry.cad_file_id)}
<span className="text-content-muted shrink-0"> onToggle={() => toggle(ev.entry.cad_file_id)}
{expanded.has(entry.cad_file_id) onReprocess={() => reprocessMut.mutate(ev.entry.cad_file_id)}
? <ChevronDown size={15} /> reprocessPending={reprocessMut.isPending}
: <ChevronRight size={15} />} />
</span> )
)}
</div>
)}
</div>
)
}
{/* Status icon */} // ── CAD file row (extracted from inline JSX above) ────────────────────────────
<StatusIcon status={entry.processing_status} />
{/* File name */} function CadFileRow({
<div className="flex-1 min-w-0"> entry, expanded, onToggle, onReprocess, reprocessPending,
<p className="font-mono text-sm text-content truncate" title={entry.original_name}> }: {
{entry.original_name} entry: CadActivityEntry
</p> expanded: boolean
<div className="flex items-center gap-3 mt-0.5 flex-wrap"> onToggle: () => void
{entry.order_numbers.length > 0 && ( onReprocess: () => void
<div className="flex gap-1 flex-wrap"> reprocessPending: boolean
{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"> return (
{n} <div>
</span> <div
))} className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none"
</div> onClick={onToggle}
)} >
{entry.file_size != null && ( {/* Type badge */}
<span className="text-xs text-content-muted">{formatBytes(entry.file_size)}</span> <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
{entry.render_log?.renderer && ( </span>
<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>
{/* Timestamp */} {/* Expand toggle */}
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block"> <span className="text-content-muted shrink-0">
<p>{new Date(entry.updated_at).toLocaleDateString('de-DE')}</p> {expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
<p>{new Date(entry.updated_at).toLocaleTimeString('de-DE')}</p> </span>
</div>
{/* Re-process button */} <StatusIcon status={entry.processing_status} />
<button
onClick={(e) => { <div className="flex-1 min-w-0">
e.stopPropagation() <p className="font-mono text-sm text-content truncate" title={entry.original_name}>
reprocessMut.mutate(entry.cad_file_id) {entry.original_name}
}} </p>
disabled={reprocessMut.isPending} <div className="flex items-center gap-3 mt-0.5 flex-wrap">
title="Re-convert STEP + regenerate thumbnail" {entry.order_numbers.length > 0 && (
className="shrink-0 p-1.5 rounded text-content-muted hover:text-accent hover:bg-surface-hover transition-colors" <div className="flex gap-1 flex-wrap">
> {entry.order_numbers.map((n) => (
<RotateCcw size={14} /> <span key={n} className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded">
</button> {n}
</span>
))}
</div> </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 ── */} <div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
{expanded.has(entry.cad_file_id) && ( <p>{new Date(entry.updated_at).toLocaleDateString('de-DE')}</p>
<div className="bg-surface-alt border-t border-border-light px-6 py-4 space-y-4"> <p>{new Date(entry.updated_at).toLocaleTimeString('de-DE')}</p>
<RenderDetails entry={entry} /> </div>
</div>
)} <button
</div> 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>
)} )}
</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">