feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan

Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
  GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
  via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
  apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
  transform (X, -Z, Y) * 0.001

Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings

Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints

Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults

Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client

Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:40:36 +01:00
parent 202b06a026
commit ca62319688
70 changed files with 6551 additions and 1130 deletions
+399 -146
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap } from 'lucide-react'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
@@ -10,6 +10,7 @@ import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
import RenderTemplateTable from '../components/admin/RenderTemplateTable'
import GlobalRenderPositionsPanel from '../components/admin/GlobalRenderPositionsPanel'
import { useAuthStore, isAdmin as checkIsAdmin } from '../store/auth'
import { listPricingTiers } from '../api/pricing'
import { listOutputTypes } from '../api/outputTypes'
@@ -29,6 +30,8 @@ export default function AdminPage() {
const isAdmin = checkIsAdmin(user)
const [showNewUser, setShowNewUser] = useState(false)
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
@@ -68,6 +71,17 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const updateUserMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: { full_name: string; role: string; is_active: boolean } }) =>
api.patch(`/admin/users/${id}`, data),
onSuccess: () => {
toast.success('User updated')
qc.invalidateQueries({ queryKey: ['admin-users'] })
setEditingUserId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
type Settings = {
thumbnail_renderer: string
blender_engine: string
@@ -167,6 +181,15 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const cleanupOrphanedMut = useMutation({
mutationFn: () => api.post('/media/cleanup-orphaned'),
onSuccess: (res) => {
toast.success(`Cleanup done: ${res.data.deleted} orphaned records deleted (${res.data.checked} checked)`)
qc.invalidateQueries({ queryKey: ['media-browser'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const reextractMetadataMut = useMutation({
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
onSuccess: (res) => {
@@ -175,6 +198,14 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const cleanupOrphanedCadMut = useMutation({
mutationFn: () => api.post('/admin/settings/cleanup-orphaned-cad-files'),
onSuccess: (res) => {
toast.success(`Deleted ${res.data.deleted_records} orphaned CAD records, freed ${res.data.freed_mb} MB`)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const recoverStuckMut = useMutation({
mutationFn: () => api.post('/admin/settings/recover-stuck-processing'),
onSuccess: (res) => {
@@ -265,19 +296,65 @@ export default function AdminPage() {
)
}
type AdminTab = 'overview' | 'users' | 'render' | 'pricing' | 'libraries' | 'config'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
const hasUnsavedChanges =
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 ||
Object.keys(smtpDraft).length > 0
const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'render', label: 'Render' },
{ id: 'pricing', label: 'Pricing' },
{ id: 'libraries', label: 'Libraries' },
{ id: 'config', label: 'Config' },
]
return (
<div>
{/* Tab header */}
<div className="px-8 pt-6 pb-0 bg-surface border-b border-border-default sticky top-0 z-10">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{hasUnsavedChanges && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm dark:bg-amber-950 dark:border-amber-800 dark:text-amber-300">
<AlertCircle size={14} />
Unsaved changes
</div>
)}
</div>
<div className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-accent text-accent'
: 'border-transparent text-content-secondary hover:text-content hover:border-border-default'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{/* ------------------------------------------------------------------ */}
{/* Pricing Summary */}
{/* ------------------------------------------------------------------ */}
<PricingSummaryCard />
{activeTab === 'overview' && <PricingSummaryCard />}
{/* ------------------------------------------------------------------ */}
{/* Users (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'users' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<h2 className="font-semibold text-content">Users</h2>
<button onClick={() => setShowNewUser(!showNewUser)} className="btn-primary">
@@ -329,35 +406,102 @@ export default function AdminPage() {
)}
<div className="divide-y divide-border-light">
{users?.map((user) => (
<div key={user.id} className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{user.full_name}</p>
<p className="text-xs text-content-muted">{user.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(user) ? 'badge-green' : 'badge-gray'}`}>
{user.role}
</span>
<span className={`badge mr-4 ${user.is_active ? 'badge-green' : 'badge-red'}`}>
{user.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${user.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(user.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
{users?.map((u) => (
<div key={u.id}>
{editingUserId === u.id ? (
<div className="px-6 py-3 bg-surface-alt space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-content-muted block mb-1">Full name</label>
<input
value={editUserDraft.full_name}
onChange={(e) => setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
/>
</div>
<div>
<label className="text-xs text-content-muted block mb-1">Role</label>
<select
value={editUserDraft.role}
onChange={(e) => setEditUserDraft((d) => ({ ...d, role: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
>
<option value="client">Client</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
<option value="global_admin">Global Admin</option>
<option value="tenant_admin">Tenant Admin</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-content cursor-pointer">
<input
type="checkbox"
checked={editUserDraft.is_active}
onChange={(e) => setEditUserDraft((d) => ({ ...d, is_active: e.target.checked }))}
className="rounded"
/>
Active
</label>
<div className="flex gap-2 ml-auto">
<button
onClick={() => setEditingUserId(null)}
className="btn-secondary text-sm"
>
Cancel
</button>
<button
onClick={() => updateUserMut.mutate({ id: u.id, data: editUserDraft })}
disabled={updateUserMut.isPending}
className="btn-primary text-sm"
>
{updateUserMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : (
<div className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{u.full_name}</p>
<p className="text-xs text-content-muted">{u.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(u) ? 'badge-green' : 'badge-gray'}`}>
{u.role}
</span>
<span className={`badge mr-4 ${u.is_active ? 'badge-green' : 'badge-red'}`}>
{u.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setEditingUserId(u.id)
setEditUserDraft({ full_name: u.full_name, role: u.role, is_active: u.is_active })
}}
className="text-content-muted hover:text-accent transition-colors mr-3"
title="Edit user"
>
<Pencil size={16} />
</button>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${u.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(u.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
))}
</div>
@@ -366,7 +510,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
@@ -788,6 +932,34 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => cleanupOrphanedMut.mutate()}
disabled={cleanupOrphanedMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Find and delete all MediaAsset DB records whose backing file is missing on disk"
>
<Trash2 size={14} className={cleanupOrphanedMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedMut.isPending ? 'Checking files…' : 'Clean Up Orphaned Media'}
</button>
<p className="text-xs text-content-muted">Removes DB records for renders whose files no longer exist on disk.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => {
if (window.confirm('Delete all orphaned STEP files (not linked to any product)? This cannot be undone.')) {
cleanupOrphanedCadMut.mutate()
}
}}
disabled={cleanupOrphanedCadMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Delete STEP files and thumbnails that are no longer linked to any product"
>
<Trash2 size={14} className={cleanupOrphanedCadMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedCadMut.isPending ? 'Deleting…' : 'Clean Up Orphaned STEP Files'}
</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={() => reextractMetadataMut.mutate()}
@@ -817,10 +989,28 @@ export default function AdminPage() {
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Global Render Positions (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Global Render Positions</h2>
<p className="text-xs text-content-muted mt-0.5">
Camera rotation presets available to all products. Per-product positions override these.
</p>
</div>
</div>
<div className="p-4">
<GlobalRenderPositionsPanel />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<FileBox size={16} className="text-content-muted" />
<div>
@@ -836,17 +1026,17 @@ export default function AdminPage() {
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{activeTab === 'libraries' && <AssetLibraryPanel />}
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
@@ -857,12 +1047,12 @@ export default function AdminPage() {
</div>
</div>
<OutputTypeTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<DollarSign size={16} className="text-content-muted" />
<div>
@@ -873,12 +1063,12 @@ export default function AdminPage() {
</div>
</div>
<PricingTierTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
@@ -966,7 +1156,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'libraries' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1017,12 +1207,12 @@ export default function AdminPage() {
)
})}
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Dashboard Widget Configuration (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<LayoutDashboard size={16} className="text-content-muted" />
@@ -1066,7 +1256,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* 3D Viewer & GLB Export Settings */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">3D Viewer & GLB Export</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1205,12 +1395,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Tessellation Quality */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Tessellation Quality</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1218,6 +1408,58 @@ export default function AdminPage() {
</p>
</div>
<div className="p-4 space-y-6">
{/* Presets */}
{(() => {
const PRESETS = [
{
label: 'Draft',
description: 'Fast export, visible faceting on large curves',
color: 'border-amber-400 text-amber-700',
values: { gltf_preview_linear_deflection: 0.2, gltf_preview_angular_deflection: 0.3, gltf_production_linear_deflection: 0.05, gltf_production_angular_deflection: 0.1 },
},
{
label: 'Standard',
description: 'Smooth curves, no fan artifacts recommended',
color: 'border-blue-400 text-blue-700',
values: { gltf_preview_linear_deflection: 0.1, gltf_preview_angular_deflection: 0.1, gltf_production_linear_deflection: 0.03, gltf_production_angular_deflection: 0.05 },
},
{
label: 'Fine',
description: 'Maximum quality, very large files, slow export',
color: 'border-emerald-400 text-emerald-700',
values: { gltf_preview_linear_deflection: 0.05, gltf_preview_angular_deflection: 0.05, gltf_production_linear_deflection: 0.01, gltf_production_angular_deflection: 0.02 },
},
]
const isActive = (preset: typeof PRESETS[0]) =>
tess.gltf_preview_linear_deflection === preset.values.gltf_preview_linear_deflection &&
tess.gltf_preview_angular_deflection === preset.values.gltf_preview_angular_deflection &&
tess.gltf_production_linear_deflection === preset.values.gltf_production_linear_deflection &&
tess.gltf_production_angular_deflection === preset.values.gltf_production_angular_deflection
return (
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">Presets</p>
<div className="flex gap-3">
{PRESETS.map(preset => (
<button
key={preset.label}
onClick={() => setTessellationDraft(preset.values)}
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${isActive(preset) ? preset.color + ' bg-opacity-10' : 'border-border-default text-content hover:border-blue-300'}`}
style={isActive(preset) ? { backgroundColor: 'var(--color-bg-surface-alt)' } : undefined}
>
<div className="font-semibold text-sm">{preset.label}</div>
<div className="text-xs text-content-muted mt-0.5">{preset.description}</div>
<div className="text-xs font-mono text-content-secondary mt-1 space-y-0.5">
<div>preview: {preset.values.gltf_preview_angular_deflection} rad / {preset.values.gltf_preview_linear_deflection} mm</div>
<div>prod: {preset.values.gltf_production_angular_deflection} rad / {preset.values.gltf_production_linear_deflection} mm</div>
</div>
</button>
))}
</div>
</div>
)
})()}
{/* Manual inputs */}
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Preview (Geometry GLB)</p>
@@ -1238,10 +1480,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.01"
min="0.01"
max="1.5"
value={tess.gltf_preview_angular_deflection ?? 0.5}
value={tess.gltf_preview_angular_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1268,10 +1510,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.005"
min="0.005"
max="1.5"
value={tess.gltf_production_angular_deflection ?? 0.2}
value={tess.gltf_production_angular_deflection ?? 0.05}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1293,12 +1535,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Material Library link */}
{/* ------------------------------------------------------------------ */}
<div className="card p-5 flex items-center justify-between">
{activeTab === 'render' && <div className="card p-5 flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">Material Library</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1308,7 +1550,106 @@ export default function AdminPage() {
<Link to="/materials" className="btn-secondary text-sm">
Open Material Library →
</Link>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* GPU Status */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && (
<div className="card">
<button
className="w-full p-5 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-3">
<Zap size={18} className="text-content-secondary" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Verify that the render-worker is using the GPU (not CPU fallback).
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-5 pb-5 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="btn-primary flex items-center gap-2"
>
{gpuProbing ? (
<><RefreshCw size={14} className="animate-spin" /> Running probe…</>
) : (
<><Zap size={14} /> Run GPU Check</>
)}
</button>
{gpuProbeResult && (
<span className="text-xs text-content-muted">
Last checked: {new Date(gpuProbeResult.timestamp).toLocaleString()}
</span>
)}
</div>
{gpuProbeResult && (
<div className="bg-surface-alt rounded-md p-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-xs text-content">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Devices</span>
<div className="space-y-0.5">
{gpuProbeResult.devices.map((d: string, i: number) => (
<span key={i} className="block text-xs text-content">{d}</span>
))}
</div>
</div>
)}
{gpuProbeResult.render_time_s != null && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Render time</span>
<span className="text-xs text-content">{gpuProbeResult.render_time_s.toFixed(2)}s</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Error</span>
<span className="text-xs text-status-error-text font-mono">{gpuProbeResult.error}</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-xs text-content-muted">No probe result yet. Click "Run GPU Check" to trigger a test render.</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div>
</div>
)
}
@@ -1393,6 +1734,7 @@ function AssetLibraryPanel() {
const [newDesc, setNewDesc] = useState('')
const [newFile, setNewFile] = useState<File | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: libraries = [] } = useQuery({
queryKey: ['asset-libraries'],
@@ -1586,95 +1928,6 @@ function AssetLibraryPanel() {
</div>
)}
{/* ------------------------------------------------------------------ */}
{/* GPU Status (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<button
className="w-full p-4 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Zap size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Check Blender GPU availability on the render worker
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeResult?.probed_at && (
<span className="text-xs text-content-muted">
Last checked: {Math.round((Date.now() - new Date(gpuProbeResult.probed_at).getTime()) / 60000)} min ago
</span>
)}
{gpuProbeExpanded ? <ChevronUp size={16} className="text-content-muted" /> : <ChevronDown size={16} className="text-content-muted" />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-6 pb-6 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{gpuProbing
? <RefreshCw size={14} className="animate-spin" />
: <Zap size={14} />
}
{gpuProbing ? 'Checking…' : 'Run GPU Check'}
</button>
{gpuProbing && (
<span className="text-xs text-content-muted">
Polling for result (up to 45s)
</span>
)}
</div>
{gpuProbeResult && (
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-content font-mono text-xs">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-content-secondary w-28 shrink-0">Error</span>
<span className="text-status-error-text text-xs">{gpuProbeResult.error}</span>
</div>
)}
{gpuProbeResult.probed_at && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Probed at</span>
<span className="text-content-muted text-xs">
{new Date(gpuProbeResult.probed_at).toLocaleString()}
</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-sm text-content-muted">
No probe result yet. Click "Run GPU Check" to trigger a check on the render worker.
</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}