fix: render pipeline + multi-tenancy bugs (B-Fix-1 through B-Fix-9)
- Remove worker-thumbnail (no Blender, was competing on thumbnail_rendering) - Move render_order_line_task to thumbnail_rendering queue (render-worker) - Restore template_service.py real implementation (fix circular import shim) - Thread tenant_id through STEP upload, Excel import, product create - Make system tables (output_types, materials, etc.) tenant_id nullable - Fix tenants frontend 307-redirect: use trailing slash /tenants/ - Remove Flamenco + Three.js from Admin UI (unsupported) - Set all output_types render_backend to celery (was flamenco) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, Server, ExternalLink, AlertTriangle, Upload, FileBox, Plus, X } from 'lucide-react'
|
||||
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import TemplateEditor from '../components/admin/TemplateEditor'
|
||||
@@ -21,9 +21,6 @@ export default function AdminPage() {
|
||||
const [showNewUser, setShowNewUser] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
|
||||
const [showFlamencoAdvanced, setShowFlamencoAdvanced] = useState(false)
|
||||
const [flamencoUrlDraft, setFlamencoUrlDraft] = useState('')
|
||||
const [workerCountDraft, setWorkerCountDraft] = useState(1)
|
||||
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
|
||||
|
||||
const { data: users } = useQuery({
|
||||
@@ -76,8 +73,6 @@ export default function AdminPage() {
|
||||
render_stall_timeout_minutes: number
|
||||
product_thumbnail_priority: string // JSON array
|
||||
render_backend: string
|
||||
flamenco_manager_url: string
|
||||
flamenco_worker_count: number
|
||||
smtp_enabled: boolean
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
@@ -159,44 +154,6 @@ export default function AdminPage() {
|
||||
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
|
||||
const smtp = { ...settings, ...smtpDraft } as Settings
|
||||
|
||||
type FlamencoStatus = {
|
||||
manager: { available: boolean; version: string | null; name: string | null; error?: string }
|
||||
workers: any[]
|
||||
manager_url: string
|
||||
}
|
||||
|
||||
const { data: flamencoStatus, refetch: refetchFlamenco } = useQuery({
|
||||
queryKey: ['flamenco-status'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/admin/settings/flamenco-status')
|
||||
return res.data as FlamencoStatus
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
enabled: isAdmin,
|
||||
})
|
||||
|
||||
const { data: actualWorkers, refetch: refetchActualWorkers } = useQuery({
|
||||
queryKey: ['flamenco-worker-actual'],
|
||||
queryFn: () => api.get('/admin/settings/flamenco-worker-actual').then(r => r.data as { running: number; available: boolean }),
|
||||
refetchInterval: 10000,
|
||||
enabled: isAdmin,
|
||||
})
|
||||
|
||||
const setWorkerCountMut = useMutation({
|
||||
mutationFn: (count: number) => api.post('/admin/settings/flamenco-worker-count', { count }),
|
||||
onSuccess: (res) => {
|
||||
const d = res.data
|
||||
if (d.current >= 0) {
|
||||
toast.success(`Workers scaled: ${d.previous} → ${d.current}`)
|
||||
} else {
|
||||
toast.warning(d.message || 'Setting saved — manual scaling may be needed')
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
refetchActualWorkers()
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<h1 className="text-2xl font-bold text-content">Admin</h1>
|
||||
@@ -261,182 +218,6 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Render Farm (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">
|
||||
<Server size={16} className="text-content-muted" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">Render Farm</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Route render jobs to Celery (stills) or Flamenco (animations).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetchFlamenco()}
|
||||
className="text-content-muted hover:text-content-secondary transition-colors"
|
||||
title="Refresh Flamenco status"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Global backend selector */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<label className="text-sm font-medium text-content-secondary shrink-0">Render backend:</label>
|
||||
{(['celery', 'flamenco', 'auto'] as const).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => updateSettingsMut.mutate({ render_backend: b })}
|
||||
disabled={updateSettingsMut.isPending}
|
||||
title={
|
||||
b === 'celery'
|
||||
? 'Celery — local Celery worker handles all render jobs (stills + animations)'
|
||||
: b === 'flamenco'
|
||||
? 'Flamenco — all jobs routed to the Flamenco render farm'
|
||||
: 'Auto — still images use Celery, animations use Flamenco'
|
||||
}
|
||||
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
|
||||
settings?.render_backend === b
|
||||
? 'text-white'
|
||||
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
style={settings?.render_backend === b ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
{b === 'celery' ? 'Celery' : b === 'flamenco' ? 'Flamenco' : 'Auto'}
|
||||
</button>
|
||||
))}
|
||||
{settings?.render_backend === 'auto' && (
|
||||
<p className="text-xs text-content-muted">Stills via Celery, animations via Flamenco</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flamenco status panel */}
|
||||
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Flamenco Status</p>
|
||||
{flamencoStatus?.manager?.available && (
|
||||
<a
|
||||
href="http://localhost:8080"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-accent hover:text-accent-hover"
|
||||
>
|
||||
Open Flamenco Web UI <ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Manager health */}
|
||||
<div className={`rounded-lg border p-3 flex items-start gap-2.5 ${
|
||||
flamencoStatus?.manager?.available
|
||||
? 'border-border-default bg-status-success-bg'
|
||||
: 'border-border-default bg-surface-alt'
|
||||
}`}>
|
||||
{flamencoStatus?.manager?.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">Manager</p>
|
||||
<p className="text-xs text-content-muted truncate">
|
||||
{flamencoStatus?.manager?.available
|
||||
? `v${flamencoStatus.manager.version || '?'}`
|
||||
: flamencoStatus?.manager?.error || 'Offline'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workers */}
|
||||
<div className="rounded-lg border border-border-default bg-surface p-3">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
Workers: {flamencoStatus?.workers?.length ?? 0}
|
||||
</p>
|
||||
{flamencoStatus?.workers && flamencoStatus.workers.length > 0 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{flamencoStatus.workers.slice(0, 5).map((w: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
w.status === 'awake' || w.status === 'active' ? 'bg-green-500' : 'bg-gray-300'
|
||||
}`} />
|
||||
<span className="text-content-secondary">{w.name || `worker-${i + 1}`}</span>
|
||||
<span className="text-content-muted">{w.status || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Worker count control */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm font-medium text-content-secondary">Worker count:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1} max={16}
|
||||
value={workerCountDraft || settings?.flamenco_worker_count || 1}
|
||||
onChange={(e) => setWorkerCountDraft(Number(e.target.value))}
|
||||
title="Number of Flamenco worker containers to run (1–16). Each worker handles one render job at a time."
|
||||
className="w-20 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setWorkerCountMut.mutate(workerCountDraft || settings?.flamenco_worker_count || 1)}
|
||||
disabled={setWorkerCountMut.isPending}
|
||||
className="px-3 py-1.5 rounded-md border border-border-default bg-surface text-content-secondary text-sm font-medium hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{setWorkerCountMut.isPending ? 'Scaling…' : 'Apply'}
|
||||
</button>
|
||||
{actualWorkers?.available ? (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
actualWorkers.running === (settings?.flamenco_worker_count || 1)
|
||||
? 'bg-status-success-bg text-status-success-text'
|
||||
: 'bg-status-warning-bg text-status-warning-text'
|
||||
}`}>
|
||||
{actualWorkers.running} running
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-content-muted">Docker socket unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced: Manager URL */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowFlamencoAdvanced(!showFlamencoAdvanced)}
|
||||
className="flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary"
|
||||
>
|
||||
{showFlamencoAdvanced ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
Advanced
|
||||
</button>
|
||||
{showFlamencoAdvanced && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className="text-xs text-content-muted shrink-0">Manager URL:</label>
|
||||
<input
|
||||
value={flamencoUrlDraft || settings?.flamenco_manager_url || ''}
|
||||
onChange={(e) => setFlamencoUrlDraft(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
||||
placeholder="http://flamenco-manager:8080"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (flamencoUrlDraft) updateSettingsMut.mutate({ flamenco_manager_url: flamencoUrlDraft } as any)
|
||||
}}
|
||||
disabled={!flamencoUrlDraft || updateSettingsMut.isPending}
|
||||
className="px-3 py-1.5 rounded-md border border-border-default bg-surface text-content-secondary text-sm hover:bg-surface-hover"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Users (admin only) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
@@ -538,7 +319,7 @@ export default function AdminPage() {
|
||||
{/* 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>
|
||||
{(['pillow', 'blender', 'threejs'] as const).map((r) => (
|
||||
{(['blender', 'pillow'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => updateSettingsMut.mutate({ thumbnail_renderer: r })}
|
||||
@@ -546,9 +327,7 @@ export default function AdminPage() {
|
||||
title={
|
||||
r === 'pillow'
|
||||
? 'Python Pillow — generates a placeholder grey image (no 3D rendering)'
|
||||
: r === 'blender'
|
||||
? 'Blender 5 — full ray-traced thumbnail via headless Blender (Cycles or EEVEE)'
|
||||
: 'Three.js — WebGL render in a headless Chromium browser (fast, no GPU required)'
|
||||
: '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
|
||||
@@ -557,7 +336,7 @@ export default function AdminPage() {
|
||||
}`}
|
||||
style={settings?.thumbnail_renderer === r ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
{r === 'pillow' ? 'Pillow (placeholder)' : r === 'blender' ? 'Blender 5' : 'Three.js (WebGL)'}
|
||||
{r === 'pillow' ? 'Pillow (placeholder)' : 'Blender 5'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -742,33 +521,6 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Three.js options — shown only when threejs is the active renderer */}
|
||||
{settings?.thumbnail_renderer === 'threejs' && (
|
||||
<div className="rounded-lg border border-purple-100 bg-purple-50 p-4 space-y-3">
|
||||
<p className="text-xs font-semibold text-purple-700 uppercase tracking-wide">Three.js (WebGL) Options</p>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Render size</span>
|
||||
{([512, 1024, 2048] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => updateSettingsMut.mutate({ threejs_render_size: size })}
|
||||
disabled={updateSettingsMut.isPending}
|
||||
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
|
||||
settings?.threejs_render_size === size
|
||||
? 'bg-purple-600 text-white border-purple-600'
|
||||
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
|
||||
}`}
|
||||
>
|
||||
{size}px{size === 512 ? ' (1×)' : size === 1024 ? ' (2×)' : ' (4×)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">
|
||||
Higher resolution = larger PNG thumbnails. 1024px recommended for most screens.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
Reference in New Issue
Block a user