Files
HartOMat/frontend/src/pages/WorkerActivity.tsx
T
2026-03-05 22:12:38 +01:00

634 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`
}