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):
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user