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:
@@ -345,6 +345,7 @@ async def create_order(
|
|||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
source_excel=body.source_excel,
|
source_excel=body.source_excel,
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(order)
|
db.add(order)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -364,6 +365,7 @@ async def create_order(
|
|||||||
lagertyp=item_data.lagertyp,
|
lagertyp=item_data.lagertyp,
|
||||||
medias_rendering=item_data.medias_rendering,
|
medias_rendering=item_data.medias_rendering,
|
||||||
components=[c.model_dump() for c in item_data.components],
|
components=[c.model_dump() for c in item_data.components],
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
|
|
||||||
@@ -381,6 +383,7 @@ async def create_order(
|
|||||||
render_position_id=line_data.render_position_id,
|
render_position_id=line_data.render_position_id,
|
||||||
gewuenschte_bildnummer=line_data.gewuenschte_bildnummer,
|
gewuenschte_bildnummer=line_data.gewuenschte_bildnummer,
|
||||||
notes=line_data.notes,
|
notes=line_data.notes,
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(line)
|
db.add(line)
|
||||||
|
|
||||||
@@ -565,6 +568,7 @@ async def split_missing_step(
|
|||||||
created_by=order.created_by,
|
created_by=order.created_by,
|
||||||
source_excel=order.source_excel,
|
source_excel=order.source_excel,
|
||||||
notes=f"Split from {order.order_number} — awaiting STEP files",
|
notes=f"Split from {order.order_number} — awaiting STEP files",
|
||||||
|
tenant_id=order.tenant_id,
|
||||||
)
|
)
|
||||||
db.add(new_order)
|
db.add(new_order)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -665,6 +669,7 @@ async def generate_lines_from_items(
|
|||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
output_type_id=type_id,
|
output_type_id=type_id,
|
||||||
gewuenschte_bildnummer=item.gewuenschte_bildnummer,
|
gewuenschte_bildnummer=item.gewuenschte_bildnummer,
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(line)
|
db.add(line)
|
||||||
existing_pairs.add(pair)
|
existing_pairs.add(pair)
|
||||||
@@ -807,6 +812,7 @@ async def add_order_line(
|
|||||||
render_position_id=body.render_position_id,
|
render_position_id=body.render_position_id,
|
||||||
gewuenschte_bildnummer=body.gewuenschte_bildnummer,
|
gewuenschte_bildnummer=body.gewuenschte_bildnummer,
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(line)
|
db.add(line)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ async def create_product(
|
|||||||
raise HTTPException(409, detail=f"Product with pim_id '{body.pim_id}' already exists")
|
raise HTTPException(409, detail=f"Product with pim_id '{body.pim_id}' already exists")
|
||||||
|
|
||||||
from app.services.product_service import create_default_positions
|
from app.services.product_service import create_default_positions
|
||||||
product = Product(**body.model_dump())
|
product = Product(**body.model_dump(), tenant_id=getattr(user, 'tenant_id', None))
|
||||||
db.add(product)
|
db.add(product)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await create_default_positions(db, product.id)
|
await create_default_positions(db, product.id)
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ async def finalize_excel(
|
|||||||
included_rows,
|
included_rows,
|
||||||
source_excel=str(excel_path),
|
source_excel=str(excel_path),
|
||||||
category_key=parsed_dict.get("category_key"),
|
category_key=parsed_dict.get("category_key"),
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Seed material aliases
|
# 5. Seed material aliases
|
||||||
@@ -260,6 +261,7 @@ async def finalize_excel(
|
|||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
source_excel=str(excel_path),
|
source_excel=str(excel_path),
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(order)
|
db.add(order)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -292,6 +294,7 @@ async def finalize_excel(
|
|||||||
for c in row.get("components", [])
|
for c in row.get("components", [])
|
||||||
],
|
],
|
||||||
cad_file_id=inherited_cad,
|
cad_file_id=inherited_cad,
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
|
|
||||||
@@ -316,6 +319,7 @@ async def finalize_excel(
|
|||||||
product_id=uuid.UUID(product_id),
|
product_id=uuid.UUID(product_id),
|
||||||
output_type_id=None,
|
output_type_id=None,
|
||||||
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(line)
|
db.add(line)
|
||||||
else:
|
else:
|
||||||
@@ -325,6 +329,7 @@ async def finalize_excel(
|
|||||||
product_id=uuid.UUID(product_id),
|
product_id=uuid.UUID(product_id),
|
||||||
output_type_id=type_id,
|
output_type_id=type_id,
|
||||||
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(line)
|
db.add(line)
|
||||||
|
|
||||||
@@ -410,6 +415,7 @@ async def upload_step(
|
|||||||
file_hash=file_hash,
|
file_hash=file_hash,
|
||||||
file_size=len(content),
|
file_size=len(content),
|
||||||
processing_status=ProcessingStatus.pending,
|
processing_status=ProcessingStatus.pending,
|
||||||
|
tenant_id=getattr(user, 'tenant_id', None),
|
||||||
)
|
)
|
||||||
db.add(cad_file)
|
db.add(cad_file)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async def lookup_product(
|
|||||||
|
|
||||||
|
|
||||||
async def lookup_or_create_product(
|
async def lookup_or_create_product(
|
||||||
db: AsyncSession, pim_id: str | None, fields: dict
|
db: AsyncSession, pim_id: str | None, fields: dict, tenant_id=None
|
||||||
) -> tuple[Product, bool]:
|
) -> tuple[Product, bool]:
|
||||||
"""Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found.
|
"""Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found.
|
||||||
|
|
||||||
@@ -120,6 +120,7 @@ async def lookup_or_create_product(
|
|||||||
components=fields.get("components", []),
|
components=fields.get("components", []),
|
||||||
cad_part_materials=fields.get("cad_part_materials", []),
|
cad_part_materials=fields.get("cad_part_materials", []),
|
||||||
source_excel=fields.get("source_excel"),
|
source_excel=fields.get("source_excel"),
|
||||||
|
tenant_id=tenant_id,
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -110,9 +110,7 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _legacy_dispatch(order_line_id: str) -> dict:
|
def _legacy_dispatch(order_line_id: str) -> dict:
|
||||||
"""Delegate to the original render_dispatcher logic (kept for backward compat)."""
|
"""Queue render_order_line_task (the working Celery render implementation)."""
|
||||||
# Import the original full implementation (not the shim) to avoid circular imports.
|
from app.tasks.step_tasks import render_order_line_task
|
||||||
# The original logic lives inline in the orders router / step_tasks path;
|
render_order_line_task.delay(order_line_id)
|
||||||
# here we re-use the existing flamenco/celery routing code.
|
return {"backend": "celery", "queued": True}
|
||||||
from app.services.render_dispatcher import dispatch_render # noqa: F401 — shim re-export
|
|
||||||
return dispatch_render(order_line_id)
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Rendering services — template resolution, dispatch, and Blender utilities."""
|
"""Rendering services — template resolution, dispatch, and Blender utilities."""
|
||||||
|
|
||||||
# Re-export from original service files for backward compatibility.
|
# Re-export from canonical service files for backward compatibility.
|
||||||
|
# template_service contains the actual sync implementations (Celery-safe).
|
||||||
from app.services.template_service import resolve_template, get_material_library_path
|
from app.services.template_service import resolve_template, get_material_library_path
|
||||||
from app.services.render_dispatcher import dispatch_render
|
from app.services.render_dispatcher import dispatch_render
|
||||||
from app.services.render_blender import find_blender, is_blender_available
|
from app.services.render_blender import find_blender, is_blender_available
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async def import_excel_to_products(
|
|||||||
parsed_rows: list[dict],
|
parsed_rows: list[dict],
|
||||||
source_excel: str,
|
source_excel: str,
|
||||||
category_key: str | None = None,
|
category_key: str | None = None,
|
||||||
|
tenant_id=None,
|
||||||
) -> ImportResult:
|
) -> ImportResult:
|
||||||
"""For each row, look up or create a Product.
|
"""For each row, look up or create a Product.
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ async def import_excel_to_products(
|
|||||||
"source_excel": source_excel,
|
"source_excel": source_excel,
|
||||||
}
|
}
|
||||||
|
|
||||||
product, was_created = await lookup_or_create_product(db, pim_id, fields)
|
product, was_created = await lookup_or_create_product(db, pim_id, fields, tenant_id=tenant_id)
|
||||||
row["product_id"] = str(product.id)
|
row["product_id"] = str(product.id)
|
||||||
row["product_created"] = was_created
|
row["product_created"] = was_created
|
||||||
# Carry forward any STEP file already linked to this product
|
# Carry forward any STEP file already linked to this product
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
# Compat shim — use app.domains.rendering.service instead
|
# Compat shim — routes to render_order_line_task (the working implementation)
|
||||||
from app.domains.rendering.service import dispatch_render
|
def dispatch_render(order_line_id: str) -> dict:
|
||||||
|
"""Queue render_order_line_task for the given order line."""
|
||||||
|
from app.tasks.step_tasks import render_order_line_task
|
||||||
|
render_order_line_task.delay(order_line_id)
|
||||||
|
return {"backend": "celery", "queued": True}
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["dispatch_render"]
|
__all__ = ["dispatch_render"]
|
||||||
|
|||||||
@@ -1,3 +1,102 @@
|
|||||||
# Compat shim — use app.domains.rendering.service instead
|
"""Render template resolution service.
|
||||||
from app.domains.rendering.service import resolve_template, get_material_library_path
|
|
||||||
__all__ = ["resolve_template", "get_material_library_path"]
|
Used from Celery tasks (sync context) to find the best matching .blend template
|
||||||
|
for a given category + output type combination.
|
||||||
|
|
||||||
|
Cascade priority (first active match wins):
|
||||||
|
1. Exact: category_key + output_type_id
|
||||||
|
2. Category only: category_key + output_type_id IS NULL
|
||||||
|
3. OT only: category_key IS NULL + output_type_id
|
||||||
|
4. Global: both NULL
|
||||||
|
5. No template → caller falls back to factory-settings behavior
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select, and_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.render_template import RenderTemplate
|
||||||
|
from app.models.system_setting import SystemSetting
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
_engine = create_engine(app_settings.database_url_sync)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_template(
|
||||||
|
category_key: str | None = None,
|
||||||
|
output_type_id: str | None = None,
|
||||||
|
) -> RenderTemplate | None:
|
||||||
|
"""Find the best matching active render template.
|
||||||
|
|
||||||
|
Uses sync SQLAlchemy — safe for Celery tasks.
|
||||||
|
"""
|
||||||
|
engine = _get_engine()
|
||||||
|
with Session(engine) as session:
|
||||||
|
active = RenderTemplate.is_active == True # noqa: E712
|
||||||
|
|
||||||
|
# 1. Exact match
|
||||||
|
if category_key and output_type_id:
|
||||||
|
row = session.execute(
|
||||||
|
select(RenderTemplate).where(and_(
|
||||||
|
active,
|
||||||
|
RenderTemplate.category_key == category_key,
|
||||||
|
RenderTemplate.output_type_id == output_type_id,
|
||||||
|
))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
|
||||||
|
# 2. Category only
|
||||||
|
if category_key:
|
||||||
|
row = session.execute(
|
||||||
|
select(RenderTemplate).where(and_(
|
||||||
|
active,
|
||||||
|
RenderTemplate.category_key == category_key,
|
||||||
|
RenderTemplate.output_type_id.is_(None),
|
||||||
|
))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
|
||||||
|
# 3. OT only
|
||||||
|
if output_type_id:
|
||||||
|
row = session.execute(
|
||||||
|
select(RenderTemplate).where(and_(
|
||||||
|
active,
|
||||||
|
RenderTemplate.category_key.is_(None),
|
||||||
|
RenderTemplate.output_type_id == output_type_id,
|
||||||
|
))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
|
||||||
|
# 4. Global fallback (both NULL)
|
||||||
|
row = session.execute(
|
||||||
|
select(RenderTemplate).where(and_(
|
||||||
|
active,
|
||||||
|
RenderTemplate.category_key.is_(None),
|
||||||
|
RenderTemplate.output_type_id.is_(None),
|
||||||
|
))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_material_library_path() -> str | None:
|
||||||
|
"""Read material_library_path from system_settings. Returns None if empty."""
|
||||||
|
engine = _get_engine()
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.key == "material_library_path")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row and row.value and row.value.strip():
|
||||||
|
return row.value.strip()
|
||||||
|
return None
|
||||||
|
|||||||
@@ -239,42 +239,12 @@ def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
|
|||||||
|
|
||||||
@celery_app.task(name="app.tasks.step_tasks.dispatch_order_line_render", queue="step_processing")
|
@celery_app.task(name="app.tasks.step_tasks.dispatch_order_line_render", queue="step_processing")
|
||||||
def dispatch_order_line_render(order_line_id: str):
|
def dispatch_order_line_render(order_line_id: str):
|
||||||
"""Thin wrapper that calls render_dispatcher.dispatch_render()."""
|
"""Route an order-line render to render_order_line_task."""
|
||||||
logger.info(f"Dispatching render for order line: {order_line_id}")
|
logger.info(f"Dispatching render for order line: {order_line_id}")
|
||||||
try:
|
render_order_line_task.delay(order_line_id)
|
||||||
from app.services.render_dispatcher import dispatch_render
|
|
||||||
result = dispatch_render(order_line_id)
|
|
||||||
logger.info(f"Dispatch result for {order_line_id}: {result}")
|
|
||||||
return result
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(f"dispatch_order_line_render failed for {order_line_id}: {exc}")
|
|
||||||
# Mark line as failed so it doesn't stay stuck in "processing"
|
|
||||||
try:
|
|
||||||
from sqlalchemy import create_engine, update as sql_update
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.config import settings as app_settings
|
|
||||||
from app.models.order_line import OrderLine
|
|
||||||
from datetime import datetime
|
|
||||||
sync_url = app_settings.database_url.replace("+asyncpg", "")
|
|
||||||
eng = create_engine(sync_url)
|
|
||||||
with Session(eng) as s:
|
|
||||||
s.execute(
|
|
||||||
sql_update(OrderLine)
|
|
||||||
.where(OrderLine.id == order_line_id)
|
|
||||||
.values(
|
|
||||||
render_status="failed",
|
|
||||||
render_completed_at=datetime.utcnow(),
|
|
||||||
render_log={"error": f"Dispatch failed: {str(exc)[:500]}"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
s.commit()
|
|
||||||
eng.dispose()
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to mark {order_line_id} as failed after dispatch error")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="step_processing", max_retries=3)
|
@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="thumbnail_rendering", max_retries=3)
|
||||||
def render_order_line_task(self, order_line_id: str):
|
def render_order_line_task(self, order_line_id: str):
|
||||||
"""Render a specific output type for an order line.
|
"""Render a specific output type for an order line.
|
||||||
|
|
||||||
|
|||||||
@@ -112,37 +112,6 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
worker-thumbnail:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
command: celery -A app.tasks.celery_app worker --loglevel=info -Q thumbnail_rendering --concurrency=1
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-schaeffler}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-schaeffler}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-schaeffler}
|
|
||||||
- POSTGRES_HOST=postgres
|
|
||||||
- POSTGRES_PORT=5432
|
|
||||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
|
||||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-changeme-in-production}
|
|
||||||
- AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-}
|
|
||||||
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-}
|
|
||||||
- AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-gpt-4o}
|
|
||||||
- AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-2024-02-01}
|
|
||||||
- UPLOAD_DIR=/app/uploads
|
|
||||||
- MINIO_URL=${MINIO_URL:-http://minio:9000}
|
|
||||||
- MINIO_USER=${MINIO_USER:-minioadmin}
|
|
||||||
- MINIO_PASSWORD=${MINIO_PASSWORD:-minioadmin}
|
|
||||||
- MINIO_BUCKET=${MINIO_BUCKET:-uploads}
|
|
||||||
volumes:
|
|
||||||
- ./backend:/app
|
|
||||||
- uploads:/app/uploads
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
render-worker:
|
render-worker:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface TenantUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTenants(): Promise<Tenant[]> {
|
export async function getTenants(): Promise<Tenant[]> {
|
||||||
const res = await api.get<Tenant[]>('/tenants')
|
const res = await api.get<Tenant[]>('/tenants/')
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import type { OutputType } from '../../api/outputTypes'
|
|||||||
import { listPricingTiers } from '../../api/pricing'
|
import { listPricingTiers } from '../../api/pricing'
|
||||||
import type { PricingTier } from '../../api/pricing'
|
import type { PricingTier } from '../../api/pricing'
|
||||||
|
|
||||||
const RENDERERS = ['threejs', 'blender', 'pillow']
|
const RENDERERS = ['blender', 'pillow']
|
||||||
const FORMATS = ['png', 'jpg', 'gltf', 'stl', 'mp4', 'webm']
|
const FORMATS = ['png', 'jpg', 'gltf', 'stl', 'mp4', 'webm']
|
||||||
const BACKENDS = ['auto', 'celery', 'flamenco']
|
|
||||||
const ALL_CATEGORIES = [
|
const ALL_CATEGORIES = [
|
||||||
{ key: 'TRB', label: 'TRB' },
|
{ key: 'TRB', label: 'TRB' },
|
||||||
{ key: 'Kugellager', label: 'Kugellager' },
|
{ key: 'Kugellager', label: 'Kugellager' },
|
||||||
@@ -173,10 +172,9 @@ export default function OutputTypeTable() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border-light text-left">
|
<tr className="border-b border-border-light text-left">
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary">Name</th>
|
<th className="px-4 py-2 font-medium text-content-secondary">Name</th>
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary" title="Renderer used to produce this output (pillow / blender / threejs / flamenco)">Renderer</th>
|
<th className="px-4 py-2 font-medium text-content-secondary" title="Renderer used to produce this output (blender / pillow)">Renderer</th>
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary" title="Output file format (jpg, png, mp4, webm)">Format</th>
|
<th className="px-4 py-2 font-medium text-content-secondary" title="Output file format (jpg, png, mp4, webm)">Format</th>
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary" title="Render backend override: auto = system default, celery = local worker, flamenco = render farm">Backend</th>
|
<th className="px-4 py-2 font-medium text-content-secondary" title="Animation — output is a video (mp4/webm turntable)">Anim</th>
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary" title="Animation — output is a video (mp4/webm turntable); uses Flamenco by default">Anim</th>
|
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary" title="Turntable animation settings: frame count, FPS, and rotation axis (animation output types only)">Turntable</th>
|
<th className="px-4 py-2 font-medium text-content-secondary" title="Turntable animation settings: frame count, FPS, and rotation axis (animation output types only)">Turntable</th>
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary" title="Transparent Background — render with alpha channel (PNG only; Blender film_transparent)">BG</th>
|
<th className="px-4 py-2 font-medium text-content-secondary" title="Transparent Background — render with alpha channel (PNG only; Blender film_transparent)">BG</th>
|
||||||
<th className="px-4 py-2 font-medium text-content-secondary" title="Cycles compute device override — auto, gpu, or cpu (Blender/Cycles only)">Device</th>
|
<th className="px-4 py-2 font-medium text-content-secondary" title="Cycles compute device override — auto, gpu, or cpu (Blender/Cycles only)">Device</th>
|
||||||
@@ -194,7 +192,7 @@ export default function OutputTypeTable() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={17} className="px-4 py-4 text-center text-content-muted">Loading…</td>
|
<td colSpan={16} className="px-4 py-4 text-center text-content-muted">Loading…</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{types?.map((ot) => (
|
{types?.map((ot) => (
|
||||||
@@ -226,15 +224,6 @@ export default function OutputTypeTable() {
|
|||||||
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
|
||||||
<select
|
|
||||||
className="input-sm"
|
|
||||||
value={editDraft.render_backend ?? ot.render_backend}
|
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, render_backend: e.target.value })}
|
|
||||||
>
|
|
||||||
{BACKENDS.map((b) => <option key={b}>{b}</option>)}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -523,15 +512,6 @@ export default function OutputTypeTable() {
|
|||||||
<td className="px-4 py-2 font-medium">{ot.name}</td>
|
<td className="px-4 py-2 font-medium">{ot.name}</td>
|
||||||
<td className="px-4 py-2 text-content-muted">{ot.renderer}</td>
|
<td className="px-4 py-2 text-content-muted">{ot.renderer}</td>
|
||||||
<td className="px-4 py-2 text-content-muted">{ot.output_format}</td>
|
<td className="px-4 py-2 text-content-muted">{ot.output_format}</td>
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
|
||||||
ot.render_backend === 'flamenco' ? 'bg-status-warning-bg text-status-warning-text' :
|
|
||||||
ot.render_backend === 'celery' ? 'bg-status-info-bg text-status-info-text' :
|
|
||||||
'bg-surface-muted text-content-muted'
|
|
||||||
}`}>
|
|
||||||
{ot.render_backend}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{ot.is_animation && (
|
{ot.is_animation && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">video</span>
|
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">video</span>
|
||||||
@@ -557,13 +537,13 @@ export default function OutputTypeTable() {
|
|||||||
{ot.transparent_bg && (
|
{ot.transparent_bg && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-sky-50 text-sky-700" title="Transparent background">alpha</span>
|
<span className="text-xs px-1.5 py-0.5 rounded bg-sky-50 text-sky-700" title="Transparent background">alpha</span>
|
||||||
)}
|
)}
|
||||||
{ot.render_settings?.bg_color && (
|
{!!ot.render_settings?.bg_color && (
|
||||||
<div className="flex items-center gap-1" title={`BG color: ${ot.render_settings.bg_color}`}>
|
<div className="flex items-center gap-1" title={`BG color: ${ot.render_settings.bg_color}`}>
|
||||||
<span
|
<span
|
||||||
className="inline-block w-4 h-4 rounded border border-border-default"
|
className="inline-block w-4 h-4 rounded border border-border-default"
|
||||||
style={{ backgroundColor: ot.render_settings.bg_color as string }}
|
style={{ backgroundColor: ot.render_settings.bg_color as string }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-content-muted font-mono">{ot.render_settings.bg_color}</span>
|
<span className="text-xs text-content-muted font-mono">{ot.render_settings.bg_color as string}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -604,7 +584,7 @@ export default function OutputTypeTable() {
|
|||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{showBlenderSettings(ot.renderer) ? (
|
{showBlenderSettings(ot.renderer) ? (
|
||||||
ot.render_settings?.samples ? (
|
ot.render_settings?.samples ? (
|
||||||
<span className="text-xs font-medium text-content-secondary">{ot.render_settings.samples}</span>
|
<span className="text-xs font-medium text-content-secondary">{ot.render_settings.samples as number}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-content-muted">default</span>
|
<span className="text-xs text-content-muted">default</span>
|
||||||
)
|
)
|
||||||
@@ -621,7 +601,7 @@ export default function OutputTypeTable() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{ot.render_settings?.noise_threshold ? (
|
{ot.render_settings?.noise_threshold ? (
|
||||||
<span className="text-xs text-content-muted">t={ot.render_settings.noise_threshold}</span>
|
<span className="text-xs text-content-muted">t={ot.render_settings.noise_threshold as string}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{ot.render_settings?.denoising_prefilter ? (
|
{ot.render_settings?.denoising_prefilter ? (
|
||||||
<span className="text-xs text-content-muted">{ot.render_settings.denoising_prefilter as string}</span>
|
<span className="text-xs text-content-muted">{ot.render_settings.denoising_prefilter as string}</span>
|
||||||
@@ -728,15 +708,6 @@ export default function OutputTypeTable() {
|
|||||||
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
|
||||||
<select
|
|
||||||
className="input-sm"
|
|
||||||
value={form.render_backend}
|
|
||||||
onChange={(e) => setForm({ ...form, render_backend: e.target.value })}
|
|
||||||
>
|
|
||||||
{BACKENDS.map((b) => <option key={b}>{b}</option>)}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
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 { Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import TemplateEditor from '../components/admin/TemplateEditor'
|
import TemplateEditor from '../components/admin/TemplateEditor'
|
||||||
@@ -21,9 +21,6 @@ export default function AdminPage() {
|
|||||||
const [showNewUser, setShowNewUser] = useState(false)
|
const [showNewUser, setShowNewUser] = useState(false)
|
||||||
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
|
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
|
||||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
|
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 [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
|
||||||
|
|
||||||
const { data: users } = useQuery({
|
const { data: users } = useQuery({
|
||||||
@@ -76,8 +73,6 @@ export default function AdminPage() {
|
|||||||
render_stall_timeout_minutes: number
|
render_stall_timeout_minutes: number
|
||||||
product_thumbnail_priority: string // JSON array
|
product_thumbnail_priority: string // JSON array
|
||||||
render_backend: string
|
render_backend: string
|
||||||
flamenco_manager_url: string
|
|
||||||
flamenco_worker_count: number
|
|
||||||
smtp_enabled: boolean
|
smtp_enabled: boolean
|
||||||
smtp_host: string
|
smtp_host: string
|
||||||
smtp_port: number
|
smtp_port: number
|
||||||
@@ -159,44 +154,6 @@ export default function AdminPage() {
|
|||||||
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
|
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
|
||||||
const smtp = { ...settings, ...smtpDraft } as 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 (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<h1 className="text-2xl font-bold text-content">Admin</h1>
|
<h1 className="text-2xl font-bold text-content">Admin</h1>
|
||||||
@@ -261,182 +218,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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) */}
|
{/* Users (admin only) */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
@@ -538,7 +319,7 @@ export default function AdminPage() {
|
|||||||
{/* Renderer picker */}
|
{/* Renderer picker */}
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<label className="text-sm font-medium text-content-secondary shrink-0">Active renderer:</label>
|
<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
|
<button
|
||||||
key={r}
|
key={r}
|
||||||
onClick={() => updateSettingsMut.mutate({ thumbnail_renderer: r })}
|
onClick={() => updateSettingsMut.mutate({ thumbnail_renderer: r })}
|
||||||
@@ -546,9 +327,7 @@ export default function AdminPage() {
|
|||||||
title={
|
title={
|
||||||
r === 'pillow'
|
r === 'pillow'
|
||||||
? 'Python Pillow — generates a placeholder grey image (no 3D rendering)'
|
? 'Python Pillow — generates a placeholder grey image (no 3D rendering)'
|
||||||
: r === 'blender'
|
: 'Blender 5 — full ray-traced thumbnail via headless Blender (Cycles or EEVEE)'
|
||||||
? '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)'
|
|
||||||
}
|
}
|
||||||
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
|
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
|
||||||
settings?.thumbnail_renderer === r
|
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}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -742,33 +521,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Output format — always visible, applies to all renderers */}
|
||||||
<div className="flex items-center gap-4 flex-wrap pt-1">
|
<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>
|
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Output format:</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user