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:
@@ -33,6 +33,7 @@ class CadActivityEntry(BaseModel):
|
||||
|
||||
class RenderJobEntry(BaseModel):
|
||||
order_line_id: str
|
||||
order_id: str | None
|
||||
order_number: str | None
|
||||
product_name: str | None
|
||||
output_type_name: str | None
|
||||
@@ -134,6 +135,7 @@ async def get_worker_activity(
|
||||
for rl in render_lines:
|
||||
render_entries.append(RenderJobEntry(
|
||||
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,
|
||||
product_name=rl.product.name if rl.product else None,
|
||||
output_type_name=rl.output_type.name if rl.output_type else None,
|
||||
|
||||
@@ -37,12 +37,12 @@ export interface CadActivityEntry {
|
||||
|
||||
export interface RenderJobEntry {
|
||||
order_line_id: string
|
||||
order_id: string | null
|
||||
order_number: string | null
|
||||
product_name: string | null
|
||||
output_type_name: string | null
|
||||
render_status: 'processing' | 'completed' | 'failed' | string
|
||||
render_backend_used: string | null
|
||||
flamenco_job_id: string | null
|
||||
render_started_at: string | null
|
||||
render_completed_at: string | null
|
||||
updated_at: string
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user