feat(J): WebSocket live-events + replace polling + fix ffmpeg turntable timeout

- fix(render): ffmpeg overlay=0:0 -> overlay=0:0:shortest=1 to prevent hang on finite PNG sequences
- feat(ws): add core/websocket.py ConnectionManager + Redis Pub/Sub subscriber loop
- feat(ws): add /api/ws WebSocket endpoint with JWT query-param auth in main.py
- feat(ws): emit render_complete/failed + cad_processing_complete events from step_tasks.py
- feat(ws): emit order_status_change events from orders router
- feat(ws): add beat_tasks.py broadcast_queue_status task (every 10s via Redis __broadcast__)
- feat(frontend): add useWebSocket hook with auto-reconnect (exponential backoff, 25s ping)
- feat(frontend): add WebSocketContext + WebSocketProvider wrapping App
- refactor(frontend): remove polling from WorkerActivity (was 5s/3s) + OrderDetail (was 5s)
- refactor(frontend): reduce polling in Layout (8s->60s) + NotificationCenter (15s->60s)
- docs: add ffmpeg shortest=1 + WebSocket JWT auth learnings to LEARNINGS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 20:49:34 +01:00
parent ceb0143cb6
commit 7a1329958d
16 changed files with 909 additions and 16 deletions
+53 -1
View File
@@ -1,11 +1,13 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
import uuid
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.config import settings
from app.database import engine, Base
from app.core.websocket import manager as ws_manager
# Import routers from domain locations
from app.domains.auth.router import router as auth_router
@@ -27,7 +29,10 @@ async def lifespan(app: FastAPI):
# Create upload directories
for subdir in ("step_files", "excel_files", "thumbnails", "renders", "blend-templates"):
Path(settings.upload_dir, subdir).mkdir(parents=True, exist_ok=True)
# Start WebSocket Redis subscriber
await ws_manager.start_redis_subscriber()
yield
await ws_manager.stop()
app = FastAPI(
@@ -86,3 +91,50 @@ app.include_router(media_router)
@app.get("/health")
async def health():
return {"status": "ok", "service": "schaefflerautomat-backend"}
@app.websocket("/api/ws")
async def websocket_endpoint(
websocket: WebSocket,
token: str = Query(..., description="JWT access token"),
):
"""WebSocket endpoint for real-time events.
Clients connect with ?token=<jwt>. Events are scoped by tenant_id.
"""
from app.utils.auth import decode_token
from app.database import AsyncSessionLocal
from sqlalchemy import select
from app.models.user import User
# Authenticate via token query param (WS cannot send Authorization header)
try:
payload = decode_token(token)
user_id = payload.get("sub")
if not user_id:
await websocket.close(code=4001)
return
except HTTPException:
await websocket.close(code=4001)
return
# Load user to get tenant_id
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user or not user.is_active:
await websocket.close(code=4001)
return
tenant_id = str(user.tenant_id) if user.tenant_id else user_id
await ws_manager.connect(websocket, tenant_id)
try:
while True:
# Keep alive — clients send periodic pings as text
await websocket.receive_text()
except WebSocketDisconnect:
await ws_manager.disconnect(websocket, tenant_id)
except Exception:
await ws_manager.disconnect(websocket, tenant_id)