feat: per-position camera settings, material alias dialog, product delete, media browser links

- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
+23
View File
@@ -243,6 +243,12 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const purgeRenderMediaMut = useMutation({
mutationFn: () => api.delete('/admin/settings/purge-render-media'),
onSuccess: (res) => toast.success(res.data.message || 'Render media purged'),
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
const smtp = { ...settings, ...smtpDraft } as Settings
@@ -1017,6 +1023,23 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Removes STEP files, thumbnails, and DB records not linked to any product.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => setConfirmState({
open: true,
title: 'Purge All Rendered Media',
message: 'Delete ALL still renders and turntable animations? Thumbnails, GLBs, and USD masters are kept. This cannot be undone.',
onConfirm: () => { purgeRenderMediaMut.mutate(); setConfirmState(s => ({ ...s, open: false })) },
})}
disabled={purgeRenderMediaMut.isPending}
className="btn-secondary text-sm w-full justify-start text-red-500"
title="Delete all still and turntable render media (files + DB records)"
>
<Trash2 size={14} className={purgeRenderMediaMut.isPending ? 'animate-spin' : ''} />
{purgeRenderMediaMut.isPending ? 'Purging…' : 'Purge All Stills & Turntables'}
</button>
<p className="text-xs text-content-muted">Deletes all rendered images and animations. Thumbnails, GLBs, and USD files are preserved.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
+63
View File
@@ -4,10 +4,12 @@ import { toast } from 'sonner'
import {
Plus, Trash2, Pencil, Check, X, FlaskConical, Search, Wand2, Download,
Wrench, Paintbrush, Shapes, HelpCircle, ChevronDown, ChevronRight, Tag,
AlertTriangle, Link,
} from 'lucide-react'
import {
listMaterials, createMaterial, updateMaterial, deleteMaterial,
seedSchaefflerMaterials, addAlias, deleteAlias, seedAliases,
batchCreateAliases,
} from '../api/materials'
import type { Material } from '../api/materials'
import MaterialWizard from '../components/MaterialWizard'
@@ -132,6 +134,25 @@ export default function MaterialsPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to remove alias'),
})
const [quickMapTarget, setQuickMapTarget] = useState<Record<string, string>>({})
const quickMapMut = useMutation({
mutationFn: ({ alias, material_id }: { alias: string; material_id: string }) =>
batchCreateAliases([{ alias, material_id }]),
onSuccess: () => {
toast.success('Alias created')
qc.invalidateQueries({ queryKey: ['materials'] })
setQuickMapTarget({})
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create alias'),
})
// Library materials (have schaeffler_code) for quick-map dropdown
const libraryMaterials = useMemo(
() => materials.filter((m) => m.schaeffler_code !== null).sort((a, b) => a.name.localeCompare(b.name)),
[materials]
)
const startEdit = (mat: Material) => {
setEditingId(mat.id)
setEditName(mat.name)
@@ -389,6 +410,48 @@ export default function MaterialsPage() {
{mat.schaeffler_code != null && (
<p className="text-xs text-content-muted font-mono">Nr: {mat.schaeffler_code}</p>
)}
{mat.schaeffler_code == null && mat.aliases.length === 0 && (
<div className="flex items-center gap-1.5 mt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
<AlertTriangle size={10} /> No alias
</span>
{quickMapTarget[mat.id] !== undefined ? (
<div className="flex items-center gap-1">
<select
className="text-[10px] border border-border-default rounded px-1 py-0.5"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
value={quickMapTarget[mat.id] ?? ''}
onChange={(e) => setQuickMapTarget((prev) => ({ ...prev, [mat.id]: e.target.value }))}
>
<option value="">Select target...</option>
{libraryMaterials.map((lm) => (
<option key={lm.id} value={lm.id}>{lm.name}</option>
))}
</select>
<button
onClick={() => quickMapTarget[mat.id] && quickMapMut.mutate({ alias: mat.name, material_id: quickMapTarget[mat.id] })}
disabled={!quickMapTarget[mat.id] || quickMapMut.isPending}
className="text-[10px] text-accent hover:text-accent-hover font-medium disabled:opacity-40"
>
Map
</button>
<button
onClick={() => setQuickMapTarget((prev) => { const n = { ...prev }; delete n[mat.id]; return n })}
className="text-[10px] text-content-muted hover:text-content"
>
<X size={10} />
</button>
</div>
) : (
<button
onClick={() => setQuickMapTarget((prev) => ({ ...prev, [mat.id]: '' }))}
className="inline-flex items-center gap-0.5 text-[10px] text-accent hover:text-accent-hover font-medium"
>
<Link size={10} /> Map to library
</button>
)}
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm text-content-muted truncate">{mat.description || '—'}</p>
+80 -7
View File
@@ -1,14 +1,17 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Search, Image, Film, Box, Layers, FileCode2,
ChevronLeft, ChevronRight, Download, Loader2,
CheckSquare, Square, X, ZoomIn, Archive,
CheckSquare, Square, X, ZoomIn, Archive, Trash2,
} from 'lucide-react'
import {
getMediaAssets,
zipDownloadAssets,
batchDeleteAssets,
} from '../api/media'
import { toast } from 'sonner'
import type { MediaAssetItem, MediaAssetType } from '../api/media'
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -142,7 +145,15 @@ function Lightbox({ asset, onClose }: { asset: MediaAssetItem; onClose: () => vo
>
<div className="flex items-center justify-between max-w-5xl mx-auto">
<div className="space-y-0.5">
{asset.product_name && <p className="font-medium">{asset.product_name}</p>}
{asset.product_name && (
asset.product_id ? (
<Link to={`/products/${asset.product_id}`} className="font-medium hover:underline">
{asset.product_name}
</Link>
) : (
<p className="font-medium">{asset.product_name}</p>
)
)}
<p className="text-xs opacity-70">
{asset.asset_type}
{asset.product_pim_id && ` · ${asset.product_pim_id}`}
@@ -268,9 +279,20 @@ function AssetCard({ asset, selected, onToggleSelect, onPreview }: AssetCardProp
)}
</div>
{asset.product_name && (
<p className="text-xs font-medium text-content truncate" title={asset.product_name}>
{asset.product_name}
</p>
asset.product_id ? (
<Link
to={`/products/${asset.product_id}`}
className="text-xs font-medium text-accent hover:text-accent-hover truncate block"
title={asset.product_name}
onClick={e => e.stopPropagation()}
>
{asset.product_name}
</Link>
) : (
<p className="text-xs font-medium text-content truncate" title={asset.product_name}>
{asset.product_name}
</p>
)
)}
{asset.product_pim_id && (
<p className="text-xs text-content-muted font-mono truncate">{asset.product_pim_id}</p>
@@ -326,6 +348,8 @@ export default function MediaBrowserPage() {
// Selection
const [selected, setSelected] = useState<Set<string>>(new Set())
const [zipping, setZipping] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
// Lightbox
const [previewAsset, setPreviewAsset] = useState<MediaAssetItem | null>(null)
@@ -379,6 +403,8 @@ export default function MediaBrowserPage() {
}
}
const qc = useQueryClient()
async function handleZipDownload() {
if (selected.size === 0) return
setZipping(true)
@@ -389,6 +415,22 @@ export default function MediaBrowserPage() {
}
}
async function handleBatchDelete() {
if (selected.size === 0) return
setDeleting(true)
try {
const result = await batchDeleteAssets(Array.from(selected))
toast.success(`Deleted ${result.deleted} asset${result.deleted !== 1 ? 's' : ''}`)
setSelected(new Set())
setConfirmDelete(false)
qc.invalidateQueries({ queryKey: ['media-browser'] })
} catch {
toast.error('Failed to delete assets')
} finally {
setDeleting(false)
}
}
return (
<div className="flex flex-col h-full">
{/* Lightbox */}
@@ -553,8 +595,39 @@ export default function MediaBrowserPage() {
: <><Archive size={14} /> Download ZIP</>
}
</button>
<div className="w-px h-5 bg-border-default" />
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-500 font-medium">Delete {selected.size} asset{selected.size !== 1 ? 's' : ''}?</span>
<button
onClick={handleBatchDelete}
disabled={deleting}
className="flex items-center gap-1 text-sm font-medium text-white bg-red-500 hover:bg-red-600 px-2.5 py-1 rounded transition-colors disabled:opacity-50"
>
{deleting
? <><Loader2 size={12} className="animate-spin" /> Deleting</>
: <><Trash2 size={12} /> Confirm</>
}
</button>
<button
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="text-sm text-content-muted hover:text-content transition-colors disabled:opacity-50"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-1.5 text-sm font-medium text-red-500 hover:text-red-400 transition-colors"
>
<Trash2 size={14} /> Delete
</button>
)}
<div className="w-px h-5 bg-border-default" />
<button
onClick={() => setSelected(new Set())}
onClick={() => { setSelected(new Set()); setConfirmDelete(false) }}
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
>
<X size={14} />
+69 -10
View File
@@ -12,7 +12,9 @@ import {
XCircle, RotateCw, Info,
} from 'lucide-react'
import { toast } from 'sonner'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
import { checkOrderMaterials, type UnmappedMaterial } from '../api/materials'
import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog'
import type { OrderItem, OrderLine } from '../api/orders'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
@@ -63,6 +65,9 @@ export default function OrderDetailPage() {
const [genLinesOpen, setGenLinesOpen] = useState(false)
const [genLinesSelected, setGenLinesSelected] = useState<Record<string, boolean>>({})
const [isDownloading, setIsDownloading] = useState(false)
const [unmappedMaterials, setUnmappedMaterials] = useState<UnmappedMaterial[]>([])
const [showMaterialDialog, setShowMaterialDialog] = useState(false)
const [checkingMaterials, setCheckingMaterials] = useState(false)
const [rejectModalOpen, setRejectModalOpen] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [rejectNotifyClient, setRejectNotifyClient] = useState(true)
@@ -105,6 +110,24 @@ export default function OrderDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Dispatch failed'),
})
async function handleDispatch() {
setCheckingMaterials(true)
try {
const result = await checkOrderMaterials(id!)
if (result.unmapped.length > 0) {
setUnmappedMaterials(result.unmapped)
setShowMaterialDialog(true)
} else {
dispatchMut.mutate()
}
} catch {
// If check fails, proceed with dispatch anyway
dispatchMut.mutate()
} finally {
setCheckingMaterials(false)
}
}
const cancelAllMut = useMutation({
mutationFn: () => cancelOrderRenders(id!),
onSuccess: (data) => {
@@ -288,18 +311,20 @@ export default function OrderDetailPage() {
)}
{canDispatch && (
<button
onClick={() => dispatchMut.mutate()}
onClick={handleDispatch}
className="btn-secondary"
disabled={dispatchMut.isPending}
disabled={dispatchMut.isPending || checkingMaterials}
>
{order.status === 'completed' ? <RefreshCw size={16} /> : rp && rp.failed > 0 ? <RefreshCw size={16} /> : <Play size={16} />}
{dispatchMut.isPending
? 'Dispatching…'
: order.status === 'completed'
? 'Re-submit Renders'
: rp && rp.failed > 0
? 'Retry Failed'
: 'Dispatch Renders'}
{checkingMaterials
? 'Checking materials…'
: dispatchMut.isPending
? 'Dispatching…'
: order.status === 'completed'
? 'Re-submit Renders'
: rp && rp.failed > 0
? 'Retry Failed'
: 'Dispatch Renders'}
</button>
)}
{canReject && (
@@ -753,6 +778,18 @@ export default function OrderDetailPage() {
)}
</div>
{/* Unmapped Materials Dialog */}
{showMaterialDialog && (
<UnmappedMaterialsDialog
unmapped={unmappedMaterials}
onResolved={() => {
setShowMaterialDialog(false)
dispatchMut.mutate()
}}
onCancel={() => setShowMaterialDialog(false)}
/>
)}
{/* Reject Order Modal */}
{rejectModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
@@ -846,6 +883,15 @@ function OrderLineRow({
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
})
const dispatchLineMut = useMutation({
mutationFn: () => dispatchLineRender(orderId, line.id),
onSuccess: () => {
toast.success('Render re-submitted')
qc.invalidateQueries({ queryKey: ['order', orderId] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Re-submit failed'),
})
const rejectLineMut = useMutation({
mutationFn: () => rejectOrderLine(orderId, line.id, rejectLineReason),
onSuccess: () => {
@@ -986,6 +1032,19 @@ function OrderLineRow({
{cancelMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />}
</button>
)}
{isPrivileged && (line.render_status === 'failed' || line.render_status === 'cancelled' || line.render_status === 'pending') && line.output_type_id && (
<button
onClick={(e) => {
e.stopPropagation()
dispatchLineMut.mutate()
}}
disabled={dispatchLineMut.isPending}
className="text-content-muted hover:text-green-500 transition-colors"
title="Re-submit this render"
>
{dispatchLineMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
</button>
)}
{line.render_log && (
<button
onClick={(e) => {
+90 -17
View File
@@ -11,7 +11,7 @@ import {
getProduct, updateProduct, uploadProductCad, saveProductCadMaterials, regenerateProduct,
reprocessProduct, reassignMaterialsFromExcel, getProductOrders, getProductRenders,
createRenderPosition, updateRenderPosition, deleteRenderPosition, deleteProductRender,
downloadProductRenders,
downloadProductRenders, deleteProduct,
} from '../api/products'
import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '../api/products'
import { listMaterials } from '../api/materials'
@@ -20,10 +20,12 @@ import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth'
import { generateGltfGeometry, resetStuckProcessing } from '../api/cad'
import { triggerUsdMasterGeneration } from '../api/sceneManifest'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
import { convertCadPartMaterials, normalizeMeshName } from '../components/cad/cadUtils'
import RenderInfoModal from '../components/renders/RenderInfoModal'
import ImageLightbox, { type LightboxItem } from '../components/shared/ImageLightbox'
function GlbDownloadButton({
label, url, filename, onGenerate, isGenerating, title,
@@ -147,6 +149,7 @@ export default function ProductDetailPage() {
const [wizardOpen, setWizardOpen] = useState(false)
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
const [showCadInfo, setShowCadInfo] = useState(false)
const [confirmDeleteProduct, setConfirmDeleteProduct] = useState(false)
const { data: product, isLoading } = useQuery({
queryKey: ['product', id],
@@ -221,6 +224,7 @@ export default function ProductDetailPage() {
const [batchLoading, setBatchLoading] = useState(false)
const [filterOutputType, setFilterOutputType] = useState<string | null>(null)
const [downloadLoading, setDownloadLoading] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const outputTypeNames = useMemo(() => {
const names = renders.map(r => r.output_type_name).filter((n): n is string => n !== null)
@@ -232,6 +236,15 @@ export default function ProductDetailPage() {
return renders.filter(r => r.output_type_name === filterOutputType)
}, [renders, filterOutputType])
// Build lightbox items from filtered image-only renders
const lightboxItems: LightboxItem[] = useMemo(
() => filteredRenders.filter(r => !r.is_video).map(r => ({
url: r.render_url,
label: [r.render_position_name, r.output_type_name].filter(Boolean).join(' — '),
})),
[filteredRenders],
)
const toggleSelect = (lineId: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
@@ -325,6 +338,15 @@ export default function ProductDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Update failed'),
})
const deleteProductMut = useMutation({
mutationFn: () => deleteProduct(id!, true),
onSuccess: () => {
toast.success('Product deleted permanently')
navigate('/products')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
const cadUploadMut = useMutation({
mutationFn: (file: File) => uploadProductCad(id!, file),
onSuccess: () => {
@@ -360,6 +382,15 @@ export default function ProductDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue GLB export'),
})
const generateUsdMasterMut = useMutation({
mutationFn: () => triggerUsdMasterGeneration(product!.cad_file_id!),
onSuccess: () => {
toast.info('USD master generation queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'usd_master'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue USD master'),
})
const resetStuckMut = useMutation({
mutationFn: () => resetStuckProcessing(product!.cad_file_id!),
onSuccess: (res) => {
@@ -504,12 +535,40 @@ export default function ProductDetailPage() {
</button>
</>
) : (
<button
className="btn-secondary text-sm"
onClick={() => setEditMode(true)}
>
<Pencil size={14} /> Edit
</button>
<>
<button
className="btn-secondary text-sm"
onClick={() => setEditMode(true)}
>
<Pencil size={14} /> Edit
</button>
{confirmDeleteProduct ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 font-medium">Delete permanently?</span>
<button
onClick={() => deleteProductMut.mutate()}
disabled={deleteProductMut.isPending}
className="px-2.5 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors disabled:opacity-50 flex items-center gap-1"
>
{deleteProductMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
Confirm
</button>
<button
onClick={() => setConfirmDeleteProduct(false)}
className="text-xs text-content-muted hover:text-content"
>
Cancel
</button>
</div>
) : (
<button
className="px-3 py-2 rounded-lg text-sm font-medium border border-red-300 text-red-500 hover:bg-red-50 transition-colors flex items-center gap-1.5"
onClick={() => setConfirmDeleteProduct(true)}
>
<Trash2 size={14} /> Delete
</button>
)}
</>
)}
</div>
)}
@@ -742,9 +801,9 @@ export default function ProductDetailPage() {
label="USD Master"
url={usdMasterUrl}
filename={`${product.name ?? product.pim_id}_master.usd`}
onGenerate={() => generateGeometryGlbMut.mutate()}
isGenerating={generateGeometryGlbMut.isPending}
title="USD canonical scene (auto-generated after Viewer GLB)"
onGenerate={() => generateUsdMasterMut.mutate()}
isGenerating={generateUsdMasterMut.isPending}
title="Regenerate USD canonical scene"
/>
</div>
</>
@@ -999,10 +1058,12 @@ export default function ProductDetailPage() {
</p>
) : (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{filteredRenders.map((r) => {
{filteredRenders.map((r, _ri) => {
const isConfirming = pendingDelete === r.order_line_id
const isDeleting = deleteRenderMut.isPending && isConfirming
const isSelected = selectedIds.has(r.order_line_id)
// Index into lightboxItems (image-only renders)
const imgIdx = r.is_video ? -1 : lightboxItems.findIndex(li => li.url === r.render_url)
return (
<div
key={r.order_line_id}
@@ -1048,11 +1109,14 @@ export default function ProductDetailPage() {
</div>
) : (
<div className="relative group">
<a
href={r.render_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => selectMode && e.preventDefault()}
<button
type="button"
className="w-full cursor-pointer"
onClick={(e) => {
if (selectMode) return
e.stopPropagation()
if (imgIdx >= 0) setLightboxIndex(imgIdx)
}}
>
<img
src={r.render_url}
@@ -1061,7 +1125,7 @@ export default function ProductDetailPage() {
isConfirming ? 'opacity-30' : isSelected ? 'opacity-80' : 'hover:opacity-90'
}`}
/>
</a>
</button>
{/* Select mode: checkbox top-left */}
{selectMode && (
@@ -1451,6 +1515,15 @@ export default function ProductDetailPage() {
title="CAD Thumbnail"
renderLog={product.cad_render_log}
/>
{/* Image lightbox */}
{lightboxIndex !== null && (
<ImageLightbox
items={lightboxItems}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex}
/>
)}
</div>
)
}
+2 -3
View File
@@ -80,9 +80,8 @@ function WorkerCard({ worker }: { worker: CeleryWorker }) {
type ScalableService = ScaleRequest['service']
const SCALABLE_SERVICES: { service: ScalableService; label: string; description: string }[] = [
{ service: 'render-worker', label: 'Render Worker', description: 'Blender renders — concurrency=1' },
{ service: 'worker', label: 'Step Worker', description: 'STEP processing — concurrency=8' },
{ service: 'worker-thumbnail', label: 'Thumbnail Worker', description: 'Thumbnail rendering' },
{ service: 'render-worker', label: 'Render Worker (asset_pipeline)', description: 'Blender renders, thumbnails, GLB, USD — concurrency=1' },
{ service: 'worker', label: 'Step Worker (step_processing)', description: 'STEP metadata extraction — concurrency=8' },
]
function ScaleControl({