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"])
VALID_RENDERERS = {"pillow", "blender"}
VALID_RENDERERS = {"blender"}
VALID_ENGINES = {"cycles", "eevee"}
VALID_FORMATS = {"jpg", "png"}
VALID_STL_QUALITIES = {"low", "high"}
@@ -459,7 +459,6 @@ async def renderer_status(
blender_available = is_blender_available()
blender_bin = find_blender()
return {
"pillow": {"available": True, "note": "Built-in (always available)"},
"blender": {
"available": blender_available,
"note": (
+170 -193
View File
@@ -167,66 +167,6 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
<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) */}
{/* ------------------------------------------------------------------ */}
@@ -307,13 +247,18 @@ export default function AdminPage() {
</div>}
{/* ------------------------------------------------------------------ */}
{/* Renderer Settings (admin only) */}
{/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */}
{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" />
<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>
<button
onClick={() => refetchStatus()}
@@ -324,36 +269,11 @@ export default function AdminPage() {
</button>
</div>
<div className="p-6 space-y-4">
{/* Renderer picker */}
<div className="flex items-center gap-4 flex-wrap">
<label className="text-sm font-medium text-content-secondary shrink-0">Active renderer:</label>
{(['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="p-6 space-y-6">
{/* ── Render Quality ───────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Render Quality</p>
<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 */}
<div className="flex items-center gap-6 flex-wrap">
@@ -415,9 +335,7 @@ export default function AdminPage() {
{/* Sample counts */}
<div className="grid grid-cols-2 gap-4 max-w-sm">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">
Cycles samples
</label>
<label className="block text-xs font-medium text-content-secondary mb-1">Cycles samples</label>
<input
type="number"
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>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">
EEVEE samples
</label>
<label className="block text-xs font-medium text-content-secondary mb-1">EEVEE samples</label>
<input
type="number"
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.`}
</p>
</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">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Max concurrent</span>
<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.
</p>
</div>
<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>
<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.
</p>
</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>
{/* 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 */}
<div className="flex items-center gap-4 flex-wrap pt-1">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Output format:</label>
{/* ── Output ───────────────────────────────────────────────────── */}
<div className="space-y-4">
<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) => (
<button
key={fmt}
@@ -674,97 +596,152 @@ export default function AdminPage() {
</div>
)
})()}
</div>{/* end Output */}
{/* Service health */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
<div
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
{/* ── Service Status ───────────────────────────────────────────── */}
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Service Status</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
<div
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" />
: <XCircle size={16} className="text-content-muted shrink-0 mt-0.5" />
}
<div className="min-w-0">
<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>
}
<div className="min-w-0">
<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>
</div>
</div>
</div>
))}
{!rendererStatus && (
<div className="col-span-3 flex items-center gap-2 text-xs text-content-muted p-2">
<Clock size={13} /> Checking service status
</div>
)}
))}
{!rendererStatus && (
<div className="flex items-center gap-2 text-xs text-content-muted p-2">
<Clock size={13} /> Checking service status
</div>
)}
</div>
</div>
{/* Process unprocessed / Regenerate thumbnails */}
<div className="pt-2 border-t border-border-light space-y-2">
<div className="flex items-center gap-3">
<button
onClick={() => processUnprocessedMut.mutate()}
disabled={processUnprocessedMut.isPending}
className="btn-secondary text-sm"
title="Queue all pending and failed STEP files that have never been successfully processed"
>
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
</button>
<p className="text-xs text-content-muted">
Queues all pending and failed STEP files for initial processing.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
className="btn-secondary text-sm"
title="Re-process all existing STEP files to regenerate thumbnails using the currently selected renderer"
>
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
</button>
<p className="text-xs text-content-muted">
Re-processes all existing STEP files with the currently selected renderer.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => generateMissingStlsMut.mutate()}
disabled={generateMissingStlsMut.isPending}
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'}
</button>
<p className="text-xs text-content-muted">
Generates low + high STL files for any completed STEP file that is missing them.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => seedWorkflowsMut.mutate()}
disabled={seedWorkflowsMut.isPending}
className="btn-secondary text-sm"
title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present"
>
<RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} />
{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>
{/* ── Maintenance ──────────────────────────────────────────────── */}
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Maintenance</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<button
onClick={() => processUnprocessedMut.mutate()}
disabled={processUnprocessedMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue all pending and failed STEP files that have never been successfully processed"
>
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
</button>
<p className="text-xs text-content-muted">Queues all pending/failed STEP files for initial processing.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Re-render thumbnails for all completed CAD files using the current Blender settings"
>
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
</button>
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingStlsMut.mutate()}
disabled={generateMissingStlsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
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'}
</button>
<p className="text-xs text-content-muted">Generates low + high STL files for completed STEP files missing them.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => seedWorkflowsMut.mutate()}
disabled={seedWorkflowsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present"
>
<RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} />
{seedWorkflowsMut.isPending ? 'Seeding…' : 'Seed Standard Workflows'}
</button>
<p className="text-xs text-content-muted">Creates the 4 standard workflow definitions if they don't exist yet.</p>
</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 */}
{/* ------------------------------------------------------------------ */}