feat(N): workflow pipeline, 3D viewer, worker management, QC tests
- workflow_builder.py: fix broken stubs, add render_order_line_still_task
(resolves step_path from DB instead of passing order_line_id as step_path)
- domains/rendering/tasks.py: add render_order_line_still_task,
export_gltf_for_order_line_task, export_blend_for_order_line_task,
generate_gltf_geometry_task (trimesh STL→GLB, no Blender needed)
- tasks/step_tasks.py: add generate_gltf_geometry_task for CadFile GLB export
- cad router: POST /{id}/generate-gltf-geometry endpoint (admin/PM)
- worker router: GET /celery-workers + POST /scale (docker compose subprocess)
- Dockerfile: pip install -e "[dev]" to enable pytest
- docker-compose.yml: docker socket + compose file mount on backend
- ThreeDViewer.tsx: mode toggle (geometry/production), wireframe, env presets,
download buttons (GLB + .blend)
- CadPreview.tsx: load gltf_geometry/gltf_production/blend_production assets
from MediaAsset table and pass URLs to ThreeDViewer
- ProductDetail.tsx: "View 3D" button → /cad/:id, "Generate GLB" button
- media router/service: cad_file_id filter on GET /api/media
- WorkerManagement.tsx: new page with worker status, queue depth, scale controls
- App.tsx + Layout.tsx: /workers route + sidebar link (admin/PM)
- tests: test_rendering_service.py, test_orders_service.py (backend)
- tests: WorkerActivity.test.tsx, WorkerManagement.test.tsx (frontend)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import TenantsPage from './pages/Tenants'
|
||||
import WorkflowEditorPage from './pages/WorkflowEditor'
|
||||
import MediaBrowserPage from './pages/MediaBrowser'
|
||||
import BillingPage from './pages/Billing'
|
||||
import WorkerManagementPage from './pages/WorkerManagement'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
@@ -104,6 +105,14 @@ export default function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<WorkerManagementPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</WebSocketProvider>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
|
||||
describe('WorkerActivity Page', () => {
|
||||
test('page module is importable', async () => {
|
||||
const module = await import('../../pages/WorkerActivity')
|
||||
expect(module.default).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker API types', () => {
|
||||
test('WorkerActivity interface shape', async () => {
|
||||
// Type-level check: the interface must have the right keys
|
||||
const activity = {
|
||||
cad_processing: [],
|
||||
active_count: 0,
|
||||
failed_count: 0,
|
||||
render_jobs: [],
|
||||
render_active_count: 0,
|
||||
render_failed_count: 0,
|
||||
}
|
||||
expect(activity.cad_processing).toBeInstanceOf(Array)
|
||||
expect(typeof activity.active_count).toBe('number')
|
||||
})
|
||||
|
||||
test('CeleryWorker interface shape', () => {
|
||||
const worker = {
|
||||
name: 'celery@worker1',
|
||||
queues: ['thumbnail_rendering'],
|
||||
active_task_count: 2,
|
||||
active_tasks: [{ name: 'render_still_task', id: 'abc' }],
|
||||
total_tasks_processed: { render_still_task: 42 },
|
||||
}
|
||||
expect(worker.queues).toContain('thumbnail_rendering')
|
||||
expect(worker.active_tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('QueueStatus interface shape', () => {
|
||||
const qs = {
|
||||
queue_depths: { step_processing: 3, thumbnail_rendering: 0 },
|
||||
pending_count: 3,
|
||||
active: [],
|
||||
reserved: [],
|
||||
pending: [],
|
||||
}
|
||||
expect(qs.queue_depths).toHaveProperty('step_processing')
|
||||
expect(qs.pending_count).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker API functions', () => {
|
||||
test('getWorkerActivity is a function', async () => {
|
||||
const { getWorkerActivity } = await import('../../api/worker')
|
||||
expect(typeof getWorkerActivity).toBe('function')
|
||||
})
|
||||
|
||||
test('getCeleryWorkers is a function', async () => {
|
||||
const { getCeleryWorkers } = await import('../../api/worker')
|
||||
expect(typeof getCeleryWorkers).toBe('function')
|
||||
})
|
||||
|
||||
test('scaleWorkers is a function', async () => {
|
||||
const { scaleWorkers } = await import('../../api/worker')
|
||||
expect(typeof scaleWorkers).toBe('function')
|
||||
})
|
||||
|
||||
test('getQueueStatus is a function', async () => {
|
||||
const { getQueueStatus } = await import('../../api/worker')
|
||||
expect(typeof getQueueStatus).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
|
||||
describe('WorkerManagement Page', () => {
|
||||
test('page module is importable', async () => {
|
||||
const module = await import('../../pages/WorkerManagement')
|
||||
expect(module.default).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('media API', () => {
|
||||
test('getMediaAssets is a function', async () => {
|
||||
const { getMediaAssets } = await import('../../api/media')
|
||||
expect(typeof getMediaAssets).toBe('function')
|
||||
})
|
||||
|
||||
test('MediaFilter supports cad_file_id', () => {
|
||||
// Type-level check: build a filter with cad_file_id
|
||||
const filter = { cad_file_id: 'some-uuid', asset_type: 'gltf_geometry' as const }
|
||||
expect(filter.cad_file_id).toBe('some-uuid')
|
||||
})
|
||||
|
||||
test('MediaAsset interface has all required fields', () => {
|
||||
const asset = {
|
||||
id: 'uuid',
|
||||
tenant_id: null,
|
||||
product_id: null,
|
||||
cad_file_id: null,
|
||||
order_line_id: null,
|
||||
workflow_run_id: null,
|
||||
asset_type: 'still' as const,
|
||||
storage_key: 'path/to/file.png',
|
||||
file_size_bytes: 1024,
|
||||
mime_type: 'image/png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
duration_s: null,
|
||||
render_config: null,
|
||||
is_archived: false,
|
||||
created_at: new Date().toISOString(),
|
||||
download_url: null,
|
||||
}
|
||||
expect(asset.asset_type).toBe('still')
|
||||
expect(asset.cad_file_id).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cad API', () => {
|
||||
test('generateGltfGeometry is a function', async () => {
|
||||
const { generateGltfGeometry } = await import('../../api/cad')
|
||||
expect(typeof generateGltfGeometry).toBe('function')
|
||||
})
|
||||
|
||||
test('getCadThumbnailUrl returns correct URL', async () => {
|
||||
const { getCadThumbnailUrl } = await import('../../api/cad')
|
||||
const url = getCadThumbnailUrl('test-uuid')
|
||||
expect(url).toBe('/api/cad/test-uuid/thumbnail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scale request validation', () => {
|
||||
test('allowed services', () => {
|
||||
const allowed = ['render-worker', 'worker', 'worker-thumbnail']
|
||||
expect(allowed).toContain('render-worker')
|
||||
expect(allowed).toContain('worker-thumbnail')
|
||||
expect(allowed).not.toContain('postgres')
|
||||
})
|
||||
})
|
||||
@@ -103,3 +103,18 @@ export async function regenerateThumbnail(
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface GenerateGltfResponse {
|
||||
status: 'queued'
|
||||
task_id: string
|
||||
cad_file_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue GLB geometry export from existing STL cache (trimesh, no Blender).
|
||||
* The STL low-quality cache must already exist.
|
||||
*/
|
||||
export async function generateGltfGeometry(cadFileId: string): Promise<GenerateGltfResponse> {
|
||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-geometry`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MediaAsset {
|
||||
export interface MediaFilter {
|
||||
product_id?: string
|
||||
order_line_id?: string
|
||||
cad_file_id?: string
|
||||
asset_type?: MediaAssetType
|
||||
skip?: number
|
||||
limit?: number
|
||||
@@ -42,6 +43,7 @@ export const getMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]>
|
||||
const params = new URLSearchParams()
|
||||
if (filters.product_id) params.set('product_id', filters.product_id)
|
||||
if (filters.order_line_id) params.set('order_line_id', filters.order_line_id)
|
||||
if (filters.cad_file_id) params.set('cad_file_id', filters.cad_file_id)
|
||||
if (filters.asset_type) params.set('asset_type', filters.asset_type)
|
||||
if (filters.skip !== undefined) params.set('skip', String(filters.skip))
|
||||
if (filters.limit !== undefined) params.set('limit', String(filters.limit))
|
||||
|
||||
@@ -123,3 +123,46 @@ export async function cancelTask(taskId: string): Promise<{ revoked: string }> {
|
||||
const res = await api.post<{ revoked: string }>(`/worker/queue/cancel/${taskId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CeleryWorkerTask {
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface CeleryWorker {
|
||||
name: string
|
||||
queues: string[]
|
||||
active_task_count: number
|
||||
active_tasks: CeleryWorkerTask[]
|
||||
total_tasks_processed: Record<string, number>
|
||||
}
|
||||
|
||||
export interface CeleryWorkersResponse {
|
||||
workers: CeleryWorker[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ScaleRequest {
|
||||
service: 'render-worker' | 'worker' | 'worker-thumbnail'
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ScaleResponse {
|
||||
service: string
|
||||
count: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export async function getCeleryWorkers(): Promise<CeleryWorkersResponse> {
|
||||
const res = await api.get<CeleryWorkersResponse>('/worker/celery-workers')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function scaleWorkers(req: ScaleRequest): Promise<ScaleResponse> {
|
||||
const res = await api.post<ScaleResponse>('/worker/scale', req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -11,24 +11,50 @@ import {
|
||||
import { Canvas, useThree, useFrame } from '@react-three/fiber'
|
||||
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
|
||||
import { toast } from 'sonner'
|
||||
import { X, Camera, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
X, Camera, Loader2, AlertTriangle, Box, Cpu, Download, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import api from '../../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ThreeDViewerProps {
|
||||
export interface ThreeDViewerProps {
|
||||
cadFileId: string
|
||||
onClose: () => void
|
||||
/** URL for the geometry-only GLB (from STL export) */
|
||||
geometryGltfUrl?: string
|
||||
/** URL for the production-quality GLB (from asset library render) */
|
||||
productionGltfUrl?: string
|
||||
/** Download URLs for GLB and .blend assets */
|
||||
downloadUrls?: {
|
||||
glb?: string
|
||||
blend?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ViewMode = 'geometry' | 'production'
|
||||
|
||||
const ENV_PRESETS = ['city', 'sunset', 'dawn', 'night', 'warehouse', 'forest', 'apartment', 'studio', 'park', 'lobby'] as const
|
||||
type EnvPreset = typeof ENV_PRESETS[number]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner model loader – separated so Suspense can catch it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GltfModel({ url }: { url: string }) {
|
||||
function GltfModel({ url, wireframe }: { url: string; wireframe: boolean }) {
|
||||
const { scene } = useGLTF(url)
|
||||
|
||||
useEffect(() => {
|
||||
scene.traverse((child: any) => {
|
||||
if (child.isMesh) {
|
||||
child.material = child.material.clone()
|
||||
child.material.wireframe = wireframe
|
||||
}
|
||||
})
|
||||
}, [scene, wireframe])
|
||||
|
||||
return <primitive object={scene} />
|
||||
}
|
||||
|
||||
@@ -50,11 +76,7 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
|
||||
if (!enabled || didCapture.current) return
|
||||
didCapture.current = true
|
||||
|
||||
// Grab the canvas as a data-URL after the current frame has been rendered
|
||||
const dataUrl = gl.domElement.toDataURL('image/png')
|
||||
|
||||
// Convert data-URL → Blob without a network fetch:
|
||||
// data:[<mediatype>][;base64],<data>
|
||||
const [header, base64Data] = dataUrl.split(',')
|
||||
const mimeMatch = header.match(/:(.*?);/)
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'
|
||||
@@ -64,7 +86,6 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
|
||||
byteArray[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: mimeType })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('thumbnail', blob, 'thumbnail.png')
|
||||
|
||||
@@ -72,14 +93,8 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
|
||||
.post(`/cad/${cadFileId}/regenerate-thumbnail`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then(() => {
|
||||
toast.success('Thumbnail captured and saved')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('Thumbnail upload failed', msg)
|
||||
toast.error('Failed to save thumbnail')
|
||||
})
|
||||
.then(() => toast.success('Thumbnail captured and saved'))
|
||||
.catch(() => toast.error('Failed to save thumbnail'))
|
||||
.finally(() => {
|
||||
didCapture.current = false
|
||||
onDone()
|
||||
@@ -90,7 +105,7 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error boundary for the GLTF loader inside Suspense
|
||||
// Error boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class GltfErrorBoundary extends Component<
|
||||
@@ -101,15 +116,12 @@ class GltfErrorBoundary extends Component<
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, _info: ErrorInfo): void {
|
||||
this.props.onError(error.message || 'Failed to parse GLTF')
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) return null
|
||||
return this.props.children
|
||||
@@ -117,7 +129,7 @@ class GltfErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading overlay (shown while model resolves inside Canvas)
|
||||
// Loading overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LoadingOverlay() {
|
||||
@@ -130,60 +142,199 @@ function LoadingOverlay() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model loader with resolved tracking
|
||||
// Model loader with ready tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ModelWithReadyProps {
|
||||
url: string
|
||||
wireframe: boolean
|
||||
onReady: () => void
|
||||
}
|
||||
|
||||
function ModelWithReady({ url, onReady }: ModelWithReadyProps) {
|
||||
function ModelWithReady({ url, wireframe, onReady }: ModelWithReadyProps) {
|
||||
const { scene } = useGLTF(url)
|
||||
|
||||
useEffect(() => {
|
||||
onReady()
|
||||
}, [onReady])
|
||||
scene.traverse((child: any) => {
|
||||
if (child.isMesh) {
|
||||
child.material = child.material.clone()
|
||||
child.material.wireframe = wireframe
|
||||
}
|
||||
})
|
||||
}, [scene, wireframe])
|
||||
|
||||
useEffect(() => { onReady() }, [onReady])
|
||||
return <primitive object={scene} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env preset dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EnvDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: EnvPreset
|
||||
onChange: (v: EnvPreset) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
{value}
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 bg-gray-800 border border-gray-700 rounded-md shadow-xl min-w-[130px]">
|
||||
{ENV_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => { onChange(p); setOpen(false) }}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-700 transition-colors ${
|
||||
p === value ? 'text-accent font-semibold' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps) {
|
||||
const modelUrl = `/api/cad/${cadFileId}/model`
|
||||
export default function ThreeDViewer({
|
||||
cadFileId,
|
||||
onClose,
|
||||
geometryGltfUrl,
|
||||
productionGltfUrl,
|
||||
downloadUrls,
|
||||
}: ThreeDViewerProps) {
|
||||
const defaultUrl = `/api/cad/${cadFileId}/model`
|
||||
|
||||
const [mode, setMode] = useState<ViewMode>('geometry')
|
||||
const [wireframe, setWireframe] = useState(false)
|
||||
const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [modelReady, setModelReady] = useState(false)
|
||||
|
||||
// Resolve the active model URL based on mode
|
||||
const activeUrl =
|
||||
mode === 'production' && productionGltfUrl
|
||||
? productionGltfUrl
|
||||
: geometryGltfUrl || defaultUrl
|
||||
|
||||
const handleModelReady = useCallback(() => setModelReady(true), [])
|
||||
const handleError = useCallback((msg: string) => setLoadError(msg), [])
|
||||
const handleCaptureDone = useCallback(() => setCapturing(false), [])
|
||||
|
||||
// Reset ready state when URL changes
|
||||
useEffect(() => {
|
||||
setModelReady(false)
|
||||
setLoadError(null)
|
||||
}, [activeUrl])
|
||||
|
||||
function handleDownload(url: string, filename: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const hasBothModes = !!(geometryGltfUrl && productionGltfUrl)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Toolbar */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800 shrink-0 gap-3 flex-wrap">
|
||||
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Mode toggle */}
|
||||
{hasBothModes && (
|
||||
<div className="flex rounded-md overflow-hidden border border-gray-700">
|
||||
<button
|
||||
onClick={() => setMode('geometry')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === 'geometry'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Box size={12} />
|
||||
Geometry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('production')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === 'production'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Cpu size={12} />
|
||||
Production
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wireframe toggle */}
|
||||
<button
|
||||
onClick={() => setWireframe((w) => !w)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors border ${
|
||||
wireframe
|
||||
? 'bg-accent border-accent text-white'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Wireframe
|
||||
</button>
|
||||
|
||||
{/* Environment preset */}
|
||||
<EnvDropdown value={envPreset} onChange={setEnvPreset} />
|
||||
|
||||
{/* Download buttons */}
|
||||
{downloadUrls?.glb && (
|
||||
<button
|
||||
onClick={() => handleDownload(downloadUrls.glb!, `${cadFileId}.glb`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
<Download size={12} />
|
||||
GLB
|
||||
</button>
|
||||
)}
|
||||
{downloadUrls?.blend && (
|
||||
<button
|
||||
onClick={() => handleDownload(downloadUrls.blend!, `${cadFileId}.blend`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
<Download size={12} />
|
||||
.blend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Capture button */}
|
||||
<button
|
||||
onClick={() => setCapturing(true)}
|
||||
disabled={capturing || !modelReady || loadError !== null}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{capturing ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Camera size={15} />
|
||||
)}
|
||||
{capturing ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
|
||||
{capturing ? 'Capturing…' : 'Capture Angle'}
|
||||
</button>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
||||
@@ -194,11 +345,8 @@ export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Viewport */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Viewport */}
|
||||
<div className="relative flex-1">
|
||||
{/* Error state */}
|
||||
{loadError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-4 z-20">
|
||||
<AlertTriangle size={48} className="text-red-400" />
|
||||
@@ -213,34 +361,31 @@ export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay – visible until model signals ready */}
|
||||
{!modelReady && !loadError && <LoadingOverlay />}
|
||||
|
||||
{/* Three.js Canvas */}
|
||||
<Canvas
|
||||
camera={{ position: [0, 2, 5], fov: 45 }}
|
||||
gl={{ preserveDrawingBuffer: true }}
|
||||
style={{ width: '100%', height: '100%', background: '#111827' }}
|
||||
>
|
||||
{/* Lights */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
|
||||
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
|
||||
|
||||
{/* GLTF model */}
|
||||
<GltfErrorBoundary onError={handleError}>
|
||||
<Suspense fallback={null}>
|
||||
<ModelWithReady url={modelUrl} onReady={handleModelReady} />
|
||||
<ModelWithReady
|
||||
key={activeUrl}
|
||||
url={activeUrl}
|
||||
wireframe={wireframe}
|
||||
onReady={handleModelReady}
|
||||
/>
|
||||
</Suspense>
|
||||
</GltfErrorBoundary>
|
||||
|
||||
{/* Camera controls */}
|
||||
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
|
||||
<Environment preset={envPreset} />
|
||||
|
||||
{/* Environment map for PBR materials */}
|
||||
<Environment preset="city" />
|
||||
|
||||
{/* Screenshot capture – only active when triggered */}
|
||||
{capturing && (
|
||||
<ScreenshotCapture
|
||||
enabled={capturing}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
|
||||
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt } from 'lucide-react'
|
||||
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server } from 'lucide-react'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import { clsx } from 'clsx'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -152,6 +152,22 @@ export default function Layout() {
|
||||
Media Browser
|
||||
</NavLink>
|
||||
)}
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<NavLink
|
||||
to="/workers"
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Server size={18} />
|
||||
Workers
|
||||
</NavLink>
|
||||
)}
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<NavLink
|
||||
to="/workflows"
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import ThreeDViewer from '../components/cad/ThreeDViewer'
|
||||
import { getMediaAssets } from '../api/media'
|
||||
|
||||
/**
|
||||
* Route: /cad/:id
|
||||
*
|
||||
* Renders the full-screen 3D viewer for a specific CAD file.
|
||||
* When the viewer is closed the user is navigated back.
|
||||
* Full-screen 3D viewer for a CAD file.
|
||||
* Passes production GLB URL if a gltf_geometry MediaAsset exists for this CAD file.
|
||||
*/
|
||||
export default function CadPreviewPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Load any geometry GLB that was generated for this CAD file
|
||||
const { data: gltfAssets } = useQuery({
|
||||
queryKey: ['media-assets', id, 'gltf_geometry'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'gltf_geometry' }),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// Load production GLB if available
|
||||
const { data: productionAssets } = useQuery({
|
||||
queryKey: ['media-assets', id, 'gltf_production'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'gltf_production' }),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// Load blend assets for download
|
||||
const { data: blendAssets } = useQuery({
|
||||
queryKey: ['media-assets', id, 'blend_production'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'blend_production' }),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-content-muted gap-4 p-8">
|
||||
<p className="text-lg">No CAD file ID provided.</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Go back
|
||||
</button>
|
||||
<div className="flex items-center justify-center h-full text-content-muted p-8">
|
||||
<p>No CAD file ID provided.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const latestGltf = gltfAssets?.[0]
|
||||
const latestProduction = productionAssets?.[0]
|
||||
const latestBlend = blendAssets?.[0]
|
||||
|
||||
return (
|
||||
<ThreeDViewer
|
||||
cadFileId={id}
|
||||
onClose={() => navigate(-1)}
|
||||
geometryGltfUrl={latestGltf?.download_url ?? undefined}
|
||||
productionGltfUrl={latestProduction?.download_url ?? undefined}
|
||||
downloadUrls={{
|
||||
glb: latestGltf?.download_url ?? undefined,
|
||||
blend: latestBlend?.download_url ?? undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useCallback, useEffect, Fragment, useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import {
|
||||
ArrowLeft, Pencil, Save, X, Box, Image,
|
||||
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter,
|
||||
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import { listMaterials } from '../api/materials'
|
||||
import MaterialInput from '../components/shared/MaterialInput'
|
||||
import MaterialWizard from '../components/MaterialWizard'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { downloadStl, generateStl } from '../api/cad'
|
||||
import { downloadStl, generateStl, generateGltfGeometry } from '../api/cad'
|
||||
|
||||
function CadStatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) return (
|
||||
@@ -48,6 +48,7 @@ const META_FIELDS: Array<{ key: keyof Product; label: string }> = [
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager'
|
||||
@@ -552,6 +553,30 @@ export default function ProductDetailPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{product.cad_file_id && (
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
|
||||
title="Open interactive 3D viewer"
|
||||
>
|
||||
<Cuboid size={12} />
|
||||
View 3D
|
||||
</button>
|
||||
)}
|
||||
{product.cad_file_id && isPrivileged && (
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() =>
|
||||
generateGltfGeometry(product.cad_file_id!)
|
||||
.then(() => toast.info('GLB geometry export queued'))
|
||||
.catch(() => toast.error('Failed to queue GLB export'))
|
||||
}
|
||||
title="Export geometry-only GLB from cached STL (trimesh, no Blender). Requires STL cache."
|
||||
>
|
||||
<Download size={12} />
|
||||
Generate GLB
|
||||
</button>
|
||||
)}
|
||||
{product.cad_file_id && isPrivileged && (
|
||||
<div className="flex flex-col gap-1 pt-1 border-t border-border-light">
|
||||
<p className="text-xs text-content-muted font-medium">STL</p>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { RefreshCw, ChevronDown, ChevronRight, Cpu, Layers, Minus, Plus } from 'lucide-react'
|
||||
import {
|
||||
getCeleryWorkers,
|
||||
getQueueStatus,
|
||||
scaleWorkers,
|
||||
type CeleryWorker,
|
||||
type ScaleRequest,
|
||||
} from '../api/worker'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WorkerCard({ worker }: { worker: CeleryWorker }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<div className="rounded-xl border border-border-default p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Cpu size={16} className="text-accent shrink-0" />
|
||||
<span className="text-sm font-medium text-content truncate">{worker.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
worker.active_task_count > 0
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-green-500/20 text-green-400'
|
||||
}`}
|
||||
>
|
||||
{worker.active_task_count > 0 ? `${worker.active_task_count} active` : 'idle'}
|
||||
</span>
|
||||
{worker.active_tasks.length > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="text-content-muted hover:text-content transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queues */}
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{worker.queues.map((q) => (
|
||||
<span
|
||||
key={q}
|
||||
className="text-xs px-2 py-0.5 rounded bg-surface-muted text-content-muted"
|
||||
>
|
||||
{q}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Active tasks */}
|
||||
{expanded && worker.active_tasks.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{worker.active_tasks.map((t) => (
|
||||
<div key={t.id} className="text-xs text-content-muted font-mono truncate">
|
||||
{t.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scale controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
function ScaleControl({
|
||||
service,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
service: ScalableService
|
||||
label: string
|
||||
description: string
|
||||
}) {
|
||||
const [count, setCount] = useState(1)
|
||||
const scaleMut = useMutation({
|
||||
mutationFn: () => scaleWorkers({ service, count }),
|
||||
onSuccess: (data) => toast.success(`${data.service} → ${data.count} instance(s)`),
|
||||
onError: (e: unknown) => {
|
||||
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
toast.error(detail ?? `Failed to scale ${service}`)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-default p-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.max(0, c - 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-semibold text-content">{count}</span>
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scaleMut.mutate()}
|
||||
disabled={scaleMut.isPending}
|
||||
className="btn-primary text-xs px-3 py-1.5 ml-2"
|
||||
>
|
||||
{scaleMut.isPending ? 'Scaling…' : 'Scale'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue depth bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function QueueDepthRow({ queue, depth }: { queue: string; depth: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-content w-44 truncate font-mono">{queue}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-surface-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, depth * 5)}%`,
|
||||
backgroundColor: depth > 10 ? 'var(--color-red-500)' : 'var(--color-accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-semibold w-8 text-right ${
|
||||
depth > 10 ? 'text-red-400' : 'text-content-muted'
|
||||
}`}
|
||||
>
|
||||
{depth}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function WorkerManagement() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: workerData, isLoading: workersLoading } = useQuery({
|
||||
queryKey: ['celery-workers'],
|
||||
queryFn: getCeleryWorkers,
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const { data: queueData, isLoading: queuesLoading } = useQuery({
|
||||
queryKey: ['queue-status'],
|
||||
queryFn: getQueueStatus,
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
function refresh() {
|
||||
qc.invalidateQueries({ queryKey: ['celery-workers'] })
|
||||
qc.invalidateQueries({ queryKey: ['queue-status'] })
|
||||
}
|
||||
|
||||
const workers = workerData?.workers ?? []
|
||||
const queueDepths = queueData?.queue_depths ?? {}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-content">Worker Management</h1>
|
||||
<p className="text-sm text-content-muted mt-1">
|
||||
Monitor active Celery workers and scale services up or down.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={refresh} className="btn-secondary flex items-center gap-2 text-sm">
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue depths */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Layers size={16} className="text-accent" />
|
||||
<h2 className="text-base font-semibold text-content">Queue Depths</h2>
|
||||
</div>
|
||||
{queuesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-6 rounded bg-surface-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : Object.keys(queueDepths).length === 0 ? (
|
||||
<p className="text-sm text-content-muted">No queue data available.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border-default p-4 space-y-3">
|
||||
{Object.entries(queueDepths).map(([queue, depth]) => (
|
||||
<QueueDepthRow key={queue} queue={queue} depth={depth} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Active workers */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu size={16} className="text-accent" />
|
||||
<h2 className="text-base font-semibold text-content">
|
||||
Active Workers
|
||||
{workers.length > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-content-muted">
|
||||
({workers.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{workersLoading ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="h-20 rounded-xl bg-surface-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : workerData?.error ? (
|
||||
<div className="rounded-xl border border-border-default p-4 text-sm text-red-400">
|
||||
Failed to fetch workers: {workerData.error}
|
||||
</div>
|
||||
) : workers.length === 0 ? (
|
||||
<div className="rounded-xl border border-border-default p-4 text-sm text-content-muted">
|
||||
No active workers detected. Make sure Celery workers are running.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{workers.map((w) => (
|
||||
<WorkerCard key={w.name} worker={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Scale controls */}
|
||||
<section>
|
||||
<h2 className="text-base font-semibold text-content mb-3">Scale Services</h2>
|
||||
<p className="text-xs text-content-muted mb-4">
|
||||
Adjust the number of container instances for each service via Docker Compose.
|
||||
Changes take effect immediately but are not persisted across deployments.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{SCALABLE_SERVICES.map((s) => (
|
||||
<ScaleControl key={s.service} {...s} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user