feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
+633
View File
@@ -0,0 +1,633 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
ExternalLink, Trash2, Ban, ListOrdered,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import {
getWorkerActivity, reprocessCadFile, CadActivityEntry, RenderLog, RenderJobEntry,
getQueueStatus, purgeQueue, cancelTask, QueueTask,
} from '../api/worker'
import LiveRenderLog from '../components/LiveRenderLog'
export default function WorkerActivityPage() {
const qc = useQueryClient()
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const { data, isLoading, dataUpdatedAt } = useQuery({
queryKey: ['worker-activity'],
queryFn: getWorkerActivity,
refetchInterval: 5000,
})
const reprocessMut = useMutation({
mutationFn: reprocessCadFile,
onSuccess: () => {
toast.success('Re-queued for full reprocessing')
qc.invalidateQueries({ queryKey: ['worker-activity'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const lastUpdated = dataUpdatedAt
? new Date(dataUpdatedAt).toLocaleTimeString('de-DE')
: '—'
const toggle = (id: string) =>
setExpanded((s) => {
const n = new Set(s)
n.has(id) ? n.delete(id) : n.add(id)
return n
})
return (
<div className="p-8 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Activity size={22} className="text-accent" />
<h1 className="text-2xl font-bold text-content">Worker Activity</h1>
<span className="ml-auto flex items-center gap-2 text-xs text-content-muted">
Auto-refresh every 5 s · Last: {lastUpdated}
<button
onClick={() => qc.invalidateQueries({ queryKey: ['worker-activity'] })}
className="p-1 rounded hover:bg-surface-muted"
title="Refresh now"
>
<RefreshCw size={13} />
</button>
</span>
</div>
{/* Queue panel */}
<QueuePanel />
{/* 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}
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}
color={data.render_failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} />
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-content-muted py-12 justify-center">
<Loader2 size={18} className="animate-spin" /> Loading activity
</div>
)}
{data && data.cad_processing.length === 0 && !isLoading && (
<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>
</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 && (
<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>
{/* Status icon */}
<StatusIcon status={entry.processing_status} />
{/* 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>
{/* 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>
{/* 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>
</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>
)}
</div>
)
}
// ── Queue panel ──────────────────────────────────────────────────────────────
function shortName(taskName: string): string {
// "app.tasks.step_tasks.regenerate_thumbnail" → "regenerate_thumbnail"
const parts = taskName.split('.')
return parts[parts.length - 1] ?? taskName
}
function firstArg(task: QueueTask): string {
const a = task.args?.[0]
if (!a) return '—'
const s = String(a)
return s.length > 28 ? s.slice(0, 12) + '…' + s.slice(-8) : s
}
function QueuePanel() {
const qc = useQueryClient()
const { data: queue, isLoading } = useQuery({
queryKey: ['worker-queue'],
queryFn: getQueueStatus,
refetchInterval: 3000,
})
const purgeMut = useMutation({
mutationFn: purgeQueue,
onSuccess: (res) => {
toast.success(res.message)
qc.invalidateQueries({ queryKey: ['worker-queue'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Purge failed'),
})
const cancelMut = useMutation({
mutationFn: cancelTask,
onSuccess: () => {
toast.success('Task revoked')
qc.invalidateQueries({ queryKey: ['worker-queue'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
})
const totalPending = queue?.pending_count ?? 0
const activeCount = queue?.active.length ?? 0
const reservedCount = queue?.reserved.length ?? 0
const isEmpty = totalPending === 0 && activeCount === 0 && reservedCount === 0
// Group pending by task name for summary
const pendingGroups: Record<string, number> = {}
for (const t of queue?.pending ?? []) {
const name = shortName(t.task_name)
pendingGroups[name] = (pendingGroups[name] ?? 0) + 1
}
return (
<div className="card overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border-light">
<ListOrdered size={15} className="text-content-muted" />
<h2 className="text-sm font-semibold text-content-secondary flex-1">Celery Queue</h2>
<button
onClick={() => qc.invalidateQueries({ queryKey: ['worker-queue'] })}
className="p-1 rounded hover:bg-surface-muted text-content-muted"
title="Refresh"
>
<RefreshCw size={13} className={isLoading ? 'animate-spin' : ''} />
</button>
{totalPending > 0 && (
<button
onClick={() => {
if (confirm(`Purge all ${totalPending} pending task(s) from the queue?`)) {
purgeMut.mutate()
}
}}
disabled={purgeMut.isPending}
className="flex items-center gap-1 px-2.5 py-1 rounded border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors"
>
<Trash2 size={12} />
Purge all ({totalPending})
</button>
)}
</div>
{/* Body */}
<div className="px-4 py-3 space-y-3">
{/* Summary chips */}
<div className="flex items-center gap-3 text-xs flex-wrap">
<span className={`font-semibold ${totalPending > 0 ? 'text-status-warning-text' : 'text-content-muted'}`}>
{totalPending} pending
</span>
<span className="text-content-muted">·</span>
<span className={`font-semibold ${activeCount > 0 ? 'text-status-info-text' : 'text-content-muted'}`}>
{activeCount} active
</span>
<span className="text-content-muted">·</span>
<span className="text-content-muted">{reservedCount} reserved</span>
{queue?.queue_depths && Object.entries(queue.queue_depths).map(([q, n]) => n > 0 && (
<span key={q} className="ml-1 px-1.5 py-0.5 rounded bg-status-warning-bg border border-border-default text-status-warning-text font-mono">
{q}: {n}
</span>
))}
</div>
{isEmpty && !isLoading && (
<p className="text-xs text-content-muted py-1">Queue is empty no pending or active tasks.</p>
)}
{/* Active tasks */}
{(queue?.active.length ?? 0) > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Active</p>
<div className="space-y-1">
{queue!.active.map((t) => (
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-status-info-bg border border-border-default px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse shrink-0" />
<span className="font-medium text-status-info-text shrink-0">{shortName(t.task_name)}</span>
<span className="text-status-info-text font-mono truncate flex-1">{firstArg(t)}</span>
{t.worker && (
<span className="text-status-info-text truncate max-w-[120px]">{t.worker.split('@')[0]}</span>
)}
<button
onClick={() => cancelMut.mutate(t.task_id)}
disabled={cancelMut.isPending}
title="Cancel (revoke + terminate)"
className="shrink-0 p-0.5 rounded text-status-info-text hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Ban size={13} />
</button>
</div>
))}
</div>
</div>
)}
{/* Reserved tasks */}
{(queue?.reserved.length ?? 0) > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Reserved (prefetched)</p>
<div className="space-y-1">
{queue!.reserved.map((t) => (
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-surface-alt border border-border-default px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-gray-400 shrink-0" />
<span className="font-medium text-content-secondary shrink-0">{shortName(t.task_name)}</span>
<span className="text-content-secondary font-mono truncate flex-1">{firstArg(t)}</span>
<button
onClick={() => cancelMut.mutate(t.task_id)}
disabled={cancelMut.isPending}
title="Cancel (revoke)"
className="shrink-0 p-0.5 rounded text-content-muted hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Ban size={13} />
</button>
</div>
))}
</div>
</div>
)}
{/* Pending breakdown (grouped by task name) */}
{totalPending > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">
Pending ({totalPending}{totalPending > 100 ? ', showing first 100' : ''})
</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(pendingGroups).map(([name, count]) => (
<span
key={name}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-default bg-status-warning-bg text-status-warning-text text-xs font-medium"
>
{name}
<span className="bg-status-warning-bg text-status-warning-text rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none border border-border-default">
{count}
</span>
</span>
))}
</div>
</div>
)}
</div>
</div>
)
}
// ── Render job row ───────────────────────────────────────────────────────────
function RenderJobRow({ job }: { job: RenderJobEntry }) {
const elapsed = job.render_started_at && job.render_completed_at
? ((new Date(job.render_completed_at).getTime() - new Date(job.render_started_at).getTime()) / 1000).toFixed(1)
: null
return (
<>
<div className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover">
<StatusIcon status={job.render_status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-content truncate">
{job.product_name || 'Unknown product'}
</span>
{job.output_type_name && (
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-light text-accent font-medium">
{job.output_type_name}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{job.order_number && (
<Link
to={`/orders`}
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'}
</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">
<Clock size={11} />{elapsed}s
</span>
)}
</div>
</div>
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
<p>{new Date(job.updated_at).toLocaleDateString('de-DE')}</p>
<p>{new Date(job.updated_at).toLocaleTimeString('de-DE')}</p>
</div>
</div>
<div className="px-4 pb-1">
<LiveRenderLog
orderLineId={job.order_line_id}
isActive={job.render_status === 'processing'}
/>
</div>
</>
)
}
// ── Render detail panel ──────────────────────────────────────────────────────
function RenderDetails({ entry }: { entry: CadActivityEntry }) {
const log = entry.render_log
return (
<div className="space-y-4">
{/* File info */}
<Section icon={<Image size={13} />} title="File">
<KVGrid>
<KV label="Name" value={entry.original_name} mono />
<KV label="Size" value={entry.file_size != null ? formatBytes(entry.file_size) : '—'} />
<KV label="Status" value={entry.processing_status} />
<KV label="Uploaded" value={new Date(entry.created_at).toLocaleString('de-DE')} />
<KV label="Last updated" value={new Date(entry.updated_at).toLocaleString('de-DE')} />
{entry.order_numbers.length > 0 && (
<KV label="Orders" value={entry.order_numbers.join(', ')} />
)}
</KVGrid>
{entry.error_message && (
<div className="mt-2 rounded bg-red-50 border border-red-200 px-3 py-2">
<p className="text-xs font-semibold text-red-600 mb-0.5">Error</p>
<pre className="text-xs text-red-700 whitespace-pre-wrap break-words">{entry.error_message}</pre>
</div>
)}
</Section>
{/* Render settings */}
{log && (
<Section icon={<Cpu size={13} />} title="Render settings">
<KVGrid>
<KV label="Renderer" value={log.renderer ?? '—'} />
{log.renderer === 'blender' && <>
<KV label="Engine" value={log.engine_used ?? log.engine ?? '—'} highlight={log.engine_used !== log.engine} />
<KV label="Samples" value={log.samples?.toString() ?? '—'} />
<KV label="Device" value={log.cycles_device ?? '—'} />
<KV label="STL quality" value={log.stl_quality ?? '—'} />
<KV label="Smooth angle" value={log.smooth_angle != null ? `${log.smooth_angle}°` : '—'} />
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
</>}
{log.renderer === 'threejs' && (
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
)}
<KV label="Output format" value={log.format ?? '—'} />
{log.fallback && <KV label="Fallback" value="Yes (Pillow placeholder)" highlight />}
</KVGrid>
</Section>
)}
{/* Timing */}
{log && (log.total_duration_s != null || log.stl_duration_s != null) && (
<Section icon={<Clock size={13} />} title="Timing">
<KVGrid>
{log.total_duration_s != null && <KV label="Total" value={`${log.total_duration_s}s`} />}
{log.stl_duration_s != null && <KV label="STEP→STL" value={`${log.stl_duration_s}s`} />}
{log.render_duration_s != null && <KV label="Render" value={`${log.render_duration_s}s`} />}
{log.stl_size_bytes != null && <KV label="STL size" value={formatBytes(log.stl_size_bytes)} />}
{log.output_size_bytes != null && <KV label="PNG size" value={formatBytes(log.output_size_bytes)} />}
{log.parts_count != null && <KV label="Mesh parts" value={log.parts_count.toString()} />}
</KVGrid>
</Section>
)}
{/* Blender log */}
{log?.log_lines && log.log_lines.length > 0 && (
<Section icon={<Terminal size={13} />} title={`Blender log (${log.log_lines.length} lines)`}>
<BlenderLog lines={log.log_lines} />
</Section>
)}
</div>
)
}
function Section({
icon, title, children,
}: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
return (
<div>
<p className="flex items-center gap-1.5 text-xs font-semibold text-content-muted uppercase tracking-wide mb-2">
{icon}{title}
</p>
{children}
</div>
)
}
function KVGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-1.5">
{children}
</div>
)
}
function KV({ label, value, mono, highlight }: {
label: string; value: string; mono?: boolean; highlight?: boolean
}) {
return (
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-wide text-content-muted">{label}</span>
<span className={`text-xs break-all ${mono ? 'font-mono' : ''} ${highlight ? 'text-status-warning-text font-medium' : 'text-content-secondary'}`}>
{value}
</span>
</div>
)
}
function BlenderLog({ lines }: { lines: string[] }) {
return (
<div className="bg-gray-900 rounded-md overflow-auto max-h-64">
<pre className="text-xs text-gray-200 p-3 leading-5 whitespace-pre-wrap">
{lines.map((l, i) => {
const color =
l.includes('ERROR') || l.includes('failed') ? 'text-red-400' :
l.includes('WARNING') || l.includes('warn') ? 'text-yellow-300' :
l.includes('Saved:') || l.includes('render done') ? 'text-green-400' :
l.includes('separated into') || l.includes('parts_count') ? 'text-cyan-400' :
'text-gray-200'
return (
<span key={i} className={`block ${color}`}>{l}</span>
)
})}
</pre>
</div>
)
}
function RendererBadge({ log }: { log: RenderLog }) {
if (log.renderer === 'blender') {
const eng = log.engine_used ?? log.engine ?? ''
const label = eng.includes('fallback')
? `Blender · Cycles (↩ fallback)`
: `Blender · ${eng}`
return (
<span className="text-xs bg-status-info-bg text-status-info-text px-1.5 py-0.5 rounded font-medium">
{label}
</span>
)
}
if (log.renderer === 'threejs') {
return (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">
Three.js
</span>
)
}
return (
<span className="text-xs bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded font-medium">
{log.renderer}
</span>
)
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 size={16} className="text-green-500 shrink-0" />
if (status === 'failed') return <XCircle size={16} className="text-red-500 shrink-0" />
if (status === 'processing') return <Loader2 size={16} className="animate-spin text-blue-500 shrink-0" />
return <Clock size={16} className="text-content-muted shrink-0" />
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="card p-4 text-center">
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-content-muted mt-0.5">{label}</p>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}