refactor(admin): remove Pillow as selectable renderer, restructure admin page

- Backend: VALID_RENDERERS = {"blender"} only; remove pillow from renderer-status response
- Frontend: remove renderer picker (pillow/blender buttons) — Blender is the only renderer
- Blender options always visible, grouped into Render Quality / Performance / Output sections
- Maintenance buttons in 2-column grid with descriptions
- Page reorder: Pricing Summary → Users → Blender Settings → Render Templates →
  Asset Libraries → Output Types → Pricing Tiers → SMTP → Templates
- Pillow code kept internally as fallback (not exposed in UI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 21:20:28 +01:00
parent 91f5b86316
commit ced64055f2
2 changed files with 171 additions and 195 deletions
+1 -2
View File
@@ -16,7 +16,7 @@ from app.utils.auth import require_admin, hash_password
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
VALID_RENDERERS = {"pillow", "blender"} VALID_RENDERERS = {"blender"}
VALID_ENGINES = {"cycles", "eevee"} VALID_ENGINES = {"cycles", "eevee"}
VALID_FORMATS = {"jpg", "png"} VALID_FORMATS = {"jpg", "png"}
VALID_STL_QUALITIES = {"low", "high"} VALID_STL_QUALITIES = {"low", "high"}
@@ -459,7 +459,6 @@ async def renderer_status(
blender_available = is_blender_available() blender_available = is_blender_available()
blender_bin = find_blender() blender_bin = find_blender()
return { return {
"pillow": {"available": True, "note": "Built-in (always available)"},
"blender": { "blender": {
"available": blender_available, "available": blender_available,
"note": ( "note": (
+170 -193
View File
@@ -167,66 +167,6 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<PricingSummaryCard /> <PricingSummaryCard />
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Pricing Tiers</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure price per rendering item by category and quality level.
</p>
</div>
</div>
<PricingTierTable />
</div>
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Output Types</h2>
<p className="text-xs text-content-muted mt-0.5">
Define what kinds of outputs orders can request (thumbnails, views, formats).
</p>
</div>
</div>
<OutputTypeTable />
</div>
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Render Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
Upload .blend studio setups matched by Category + Output Type. Geometry is imported into the template at render time.
</p>
</div>
</div>
<div className="p-4">
<RenderTemplateTable />
</div>
{/* Material Library sub-section */}
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Users (admin only) */} {/* Users (admin only) */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
@@ -307,13 +247,18 @@ export default function AdminPage() {
</div>} </div>}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Renderer Settings (admin only) */} {/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card"> {isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between"> <div className="p-4 border-b border-border-default flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings size={16} className="text-content-muted" /> <Settings size={16} className="text-content-muted" />
<h2 className="font-semibold text-content">Thumbnail Renderer</h2> <div>
<h2 className="font-semibold text-content">Blender Render Settings</h2>
<p className="text-xs text-content-muted mt-0.5">
Render quality, performance and thumbnail output options for Blender 5.
</p>
</div>
</div> </div>
<button <button
onClick={() => refetchStatus()} onClick={() => refetchStatus()}
@@ -324,36 +269,11 @@ export default function AdminPage() {
</button> </button>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-6">
{/* Renderer picker */} {/* ── Render Quality ───────────────────────────────────────────── */}
<div className="flex items-center gap-4 flex-wrap"> <div className="space-y-4">
<label className="text-sm font-medium text-content-secondary shrink-0">Active renderer:</label> <p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Render Quality</p>
{(['blender', 'pillow'] as const).map((r) => (
<button
key={r}
onClick={() => updateSettingsMut.mutate({ thumbnail_renderer: r })}
disabled={updateSettingsMut.isPending}
title={
r === 'pillow'
? 'Python Pillow — generates a placeholder grey image (no 3D rendering)'
: 'Blender 5 — full ray-traced thumbnail via headless Blender (Cycles or EEVEE)'
}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
settings?.thumbnail_renderer === r
? 'text-white'
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
}`}
style={settings?.thumbnail_renderer === r ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{r === 'pillow' ? 'Pillow (placeholder)' : 'Blender 5'}
</button>
))}
</div>
{/* Blender options — shown only when blender is the active renderer */}
{settings?.thumbnail_renderer === 'blender' && (
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4"> <div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Blender 5 Options</p>
{/* Engine */} {/* Engine */}
<div className="flex items-center gap-6 flex-wrap"> <div className="flex items-center gap-6 flex-wrap">
@@ -415,9 +335,7 @@ export default function AdminPage() {
{/* Sample counts */} {/* Sample counts */}
<div className="grid grid-cols-2 gap-4 max-w-sm"> <div className="grid grid-cols-2 gap-4 max-w-sm">
<div> <div>
<label className="block text-xs font-medium text-content-secondary mb-1"> <label className="block text-xs font-medium text-content-secondary mb-1">Cycles samples</label>
Cycles samples
</label>
<input <input
type="number" type="number"
min={1} max={4096} step={32} min={1} max={4096} step={32}
@@ -429,9 +347,7 @@ export default function AdminPage() {
<p className="text-xs text-content-muted mt-0.5">Higher = better quality, slower</p> <p className="text-xs text-content-muted mt-0.5">Higher = better quality, slower</p>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-content-secondary mb-1"> <label className="block text-xs font-medium text-content-secondary mb-1">EEVEE samples</label>
EEVEE samples
</label>
<input <input
type="number" type="number"
min={1} max={1024} step={16} min={1} max={1024} step={16}
@@ -485,8 +401,13 @@ export default function AdminPage() {
: `Faces with edges sharper than ${blender.blender_smooth_angle ?? 30}° stay hard; others smooth. 30° works well for most mechanical parts.`} : `Faces with edges sharper than ${blender.blender_smooth_angle ?? 30}° stay hard; others smooth. 30° works well for most mechanical parts.`}
</p> </p>
</div> </div>
</div>
</div>
{/* Max concurrent renders */} {/* ── Performance ──────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Performance</p>
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Max concurrent</span> <span className="text-sm font-medium text-content-secondary w-28 shrink-0">Max concurrent</span>
<input <input
@@ -501,7 +422,6 @@ export default function AdminPage() {
Max parallel Blender render jobs (116). Higher values use more RAM (~400 MB each). Applied live without restart. Max parallel Blender render jobs (116). Higher values use more RAM (~400 MB each). Applied live without restart.
</p> </p>
</div> </div>
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Stall timeout</span> <span className="text-sm font-medium text-content-secondary w-28 shrink-0">Stall timeout</span>
<input <input
@@ -516,23 +436,25 @@ export default function AdminPage() {
Minutes before a stuck render job is auto-restarted (1010080). Checked every 5 min by the watchdog. Minutes before a stuck render job is auto-restarted (1010080). Checked every 5 min by the watchdog.
</p> </p>
</div> </div>
{/* Save blender options */}
{Object.keys(blenderDraft).length > 0 && (
<button
onClick={() => updateSettingsMut.mutate(blenderDraft)}
disabled={updateSettingsMut.isPending}
className="btn-primary text-sm"
>
{updateSettingsMut.isPending ? 'Saving…' : 'Save Blender Options'}
</button>
)}
</div> </div>
</div>
{/* Save button — appears when draft has unsaved changes */}
{Object.keys(blenderDraft).length > 0 && (
<button
onClick={() => updateSettingsMut.mutate(blenderDraft)}
disabled={updateSettingsMut.isPending}
className="btn-primary text-sm"
>
{updateSettingsMut.isPending ? 'Saving…' : 'Save Settings'}
</button>
)} )}
{/* Output format — always visible, applies to all renderers */} {/* ── Output ───────────────────────────────────────────────────── */}
<div className="flex items-center gap-4 flex-wrap pt-1"> <div className="space-y-4">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Output format:</label> <p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Output</p>
<div className="flex items-center gap-4 flex-wrap">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Thumbnail format</label>
{(['jpg', 'png'] as const).map((fmt) => ( {(['jpg', 'png'] as const).map((fmt) => (
<button <button
key={fmt} key={fmt}
@@ -674,97 +596,152 @@ export default function AdminPage() {
</div> </div>
) )
})()} })()}
</div>{/* end Output */}
{/* Service health */} {/* ── Service Status ───────────────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3"> <div className="space-y-3">
{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => ( <p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Service Status</p>
<div <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
key={name} {rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
className={`rounded-lg border p-3 flex items-start gap-2.5 ${ <div
info.available ? 'border-border-default bg-status-success-bg' : 'border-border-default bg-surface-alt' key={name}
}`} className={`rounded-lg border p-3 flex items-start gap-2.5 ${
> info.available ? 'border-border-default bg-status-success-bg' : 'border-border-default bg-surface-alt'
{info.available }`}
? <CheckCircle2 size={16} className="text-green-500 shrink-0 mt-0.5" /> >
: info.url === null {info.available
? <CheckCircle2 size={16} className="text-green-500 shrink-0 mt-0.5" /> ? <CheckCircle2 size={16} className="text-green-500 shrink-0 mt-0.5" />
: <XCircle size={16} className="text-content-muted shrink-0 mt-0.5" /> : <XCircle size={16} className="text-content-muted shrink-0 mt-0.5" />
} }
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-content capitalize">{name}</p> <p className="text-sm font-semibold text-content capitalize">{name}</p>
<p className="text-xs text-content-muted truncate">{info.note || (info.available ? 'Online' : 'Offline')}</p> <p className="text-xs text-content-muted truncate">{info.note || (info.available ? 'Online' : 'Offline')}</p>
</div>
</div> </div>
</div> ))}
))} {!rendererStatus && (
{!rendererStatus && ( <div className="flex items-center gap-2 text-xs text-content-muted p-2">
<div className="col-span-3 flex items-center gap-2 text-xs text-content-muted p-2"> <Clock size={13} /> Checking service status
<Clock size={13} /> Checking service status </div>
</div> )}
)} </div>
</div> </div>
{/* Process unprocessed / Regenerate thumbnails */} {/* ── Maintenance ──────────────────────────────────────────────── */}
<div className="pt-2 border-t border-border-light space-y-2"> <div className="space-y-3">
<div className="flex items-center gap-3"> <p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Maintenance</p>
<button <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
onClick={() => processUnprocessedMut.mutate()} <div className="flex flex-col gap-1">
disabled={processUnprocessedMut.isPending} <button
className="btn-secondary text-sm" onClick={() => processUnprocessedMut.mutate()}
title="Queue all pending and failed STEP files that have never been successfully processed" disabled={processUnprocessedMut.isPending}
> className="btn-secondary text-sm w-full justify-start"
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} /> title="Queue all pending and failed STEP files that have never been successfully processed"
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'} >
</button> <RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
<p className="text-xs text-content-muted"> {processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
Queues all pending and failed STEP files for initial processing. </button>
</p> <p className="text-xs text-content-muted">Queues all pending/failed STEP files for initial processing.</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-col gap-1">
<button <button
onClick={() => regenerateMut.mutate()} onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending} disabled={regenerateMut.isPending}
className="btn-secondary text-sm" className="btn-secondary text-sm w-full justify-start"
title="Re-process all existing STEP files to regenerate thumbnails using the currently selected renderer" title="Re-render thumbnails for all completed CAD files using the current Blender settings"
> >
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} /> <RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'} {regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
</button> </button>
<p className="text-xs text-content-muted"> <p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
Re-processes all existing STEP files with the currently selected renderer. </div>
</p> <div className="flex flex-col gap-1">
</div> <button
<div className="flex items-center gap-3"> onClick={() => generateMissingStlsMut.mutate()}
<button disabled={generateMissingStlsMut.isPending}
onClick={() => generateMissingStlsMut.mutate()} className="btn-secondary text-sm w-full justify-start"
disabled={generateMissingStlsMut.isPending} title="Queue STL conversion for every low/high quality that is not yet cached on disk"
className="btn-secondary text-sm" >
title="Queue STL conversion for every low/high quality that is not yet cached on disk" <RefreshCw size={14} className={generateMissingStlsMut.isPending ? 'animate-spin' : ''} />
> {generateMissingStlsMut.isPending ? 'Queueing…' : 'Generate Missing STLs'}
<RefreshCw size={14} className={generateMissingStlsMut.isPending ? 'animate-spin' : ''} /> </button>
{generateMissingStlsMut.isPending ? 'Queueing…' : 'Generate Missing STLs'} <p className="text-xs text-content-muted">Generates low + high STL files for completed STEP files missing them.</p>
</button> </div>
<p className="text-xs text-content-muted"> <div className="flex flex-col gap-1">
Generates low + high STL files for any completed STEP file that is missing them. <button
</p> onClick={() => seedWorkflowsMut.mutate()}
</div> disabled={seedWorkflowsMut.isPending}
<div className="flex items-center gap-3"> className="btn-secondary text-sm w-full justify-start"
<button title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present"
onClick={() => seedWorkflowsMut.mutate()} >
disabled={seedWorkflowsMut.isPending} <RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} />
className="btn-secondary text-sm" {seedWorkflowsMut.isPending ? 'Seeding…' : 'Seed Standard Workflows'}
title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present" </button>
> <p className="text-xs text-content-muted">Creates the 4 standard workflow definitions if they don't exist yet.</p>
<RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} /> </div>
{seedWorkflowsMut.isPending ? 'Seeding…' : 'Seed Standard Workflows'}
</button>
<p className="text-xs text-content-muted">
Creates the 4 standard workflow definitions (still/turntable/multi-angle) if they don't exist.
</p>
</div> </div>
</div> </div>
</div> </div>
</div>} </div>}
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Render Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
Upload .blend studio setups matched by Category + Output Type. Geometry is imported into the template at render time.
</p>
</div>
</div>
<div className="p-4">
<RenderTemplateTable />
</div>
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Output Types</h2>
<p className="text-xs text-content-muted mt-0.5">
Define what kinds of outputs orders can request (thumbnails, views, formats).
</p>
</div>
</div>
<OutputTypeTable />
</div>
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Pricing Tiers</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure price per rendering item by category and quality level.
</p>
</div>
</div>
<PricingTierTable />
</div>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */} {/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}