From 32b12ff0a649e7a586527e94a7de4bb0da2b45d9 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 1 May 2026 11:58:29 +0100 Subject: [PATCH] =?UTF-8?q?feat(ux):=20P2=20role=20UX=20=E2=80=94=20review?= =?UTF-8?q?er=20queue,=20dashboard=20widgets,=20org=20filter,=20WS=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.3: VttEditor sticky banner + Re-translate wired into QCDetail Phase 3.1: RoleGate on /briefs/* (PM/admin/production only) Phase 3.2: LinguistQueue — sortable Assigned column, defaultRole prop Phase 3.3: ReviewerQueue component + /qc/reviewer-queue route + sidebar entry Phase 3.4: PM dashboard — Overdue and Stuck >24h widgets Phase 3.5: Production dashboard — Awaiting Upload and Pending QC Handoff widgets Phase 3.6: Admin UserList — org_id filter dropdown (uses listOrganizations) WebSocket: onTerminalClose callback + error toast in GlobalWebSocketContext Runbook: Apache ProxyTimeout ≥60s recommendation for WebSocket keepalives Backend: fix F841 unused variable in test_cross_tenant_isolation.py Co-Authored-By: Claude Sonnet 4.6 --- .../tests/unit/test_cross_tenant_isolation.py | 10 +- docs/project/runbook.md | 435 ++++++++++++------ frontend/src/App.tsx | 20 +- frontend/src/components/Layout/Sidebar.tsx | 6 + .../src/components/VttEditor/VttEditor.tsx | 41 +- .../src/contexts/GlobalWebSocketContext.tsx | 12 +- frontend/src/hooks/useJobStatusWebSocket.ts | 12 +- frontend/src/hooks/useUsers.ts | 1 + frontend/src/lib/api.ts | 2 + frontend/src/routes/Dashboard.tsx | 89 +++- frontend/src/routes/admin/QCDetail.tsx | 34 ++ frontend/src/routes/admin/UserList.tsx | 24 + frontend/src/routes/jobs/LinguistQueue.tsx | 73 ++- frontend/src/routes/jobs/ReviewerQueue.tsx | 5 + 14 files changed, 579 insertions(+), 185 deletions(-) create mode 100644 frontend/src/routes/jobs/ReviewerQueue.tsx diff --git a/backend/tests/unit/test_cross_tenant_isolation.py b/backend/tests/unit/test_cross_tenant_isolation.py index 57adc69..ed8d816 100644 --- a/backend/tests/unit/test_cross_tenant_isolation.py +++ b/backend/tests/unit/test_cross_tenant_isolation.py @@ -9,14 +9,14 @@ to project-based checks. MT-18: list_for_reviewer must only return jobs from the reviewer's orgs. """ -import pytest from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from fastapi import HTTPException from app.core.dependencies import assert_job_in_user_org, get_user_org_ids from app.models.user import User, UserRole - # ── Helpers ──────────────────────────────────────────────────────────────────── def _make_user(role: UserRole, user_id: str = "user-a") -> User: @@ -184,7 +184,6 @@ class TestListForReviewerOrgIsolation: """Verify list_for_reviewer only surfaces jobs from the reviewer's own orgs.""" def _make_job(self, job_id: str, org_id: str, reviewer_id: str) -> dict: - from bson import ObjectId return { "_id": job_id, "title": f"Job {job_id}", @@ -200,15 +199,16 @@ class TestListForReviewerOrgIsolation: @pytest.mark.asyncio async def test_reviewer_only_sees_own_org_jobs(self): + from unittest.mock import AsyncMock, patch + from app.services.language_qc import list_for_reviewer - from unittest.mock import patch, AsyncMock reviewer_id = "reviewer-a" org_a = "org-a" org_b = "org-b" job_own = self._make_job("job-own", org_a, reviewer_id) - job_other = self._make_job("job-other", org_b, reviewer_id) + _job_other = self._make_job("job-other", org_b, reviewer_id) # cross-org job, must not appear db = MagicMock() diff --git a/docs/project/runbook.md b/docs/project/runbook.md index 1e1f770..7ea4141 100644 --- a/docs/project/runbook.md +++ b/docs/project/runbook.md @@ -1,213 +1,356 @@ # Runbook — Accessible Video Processing Platform - + + + + + + -## Local Development Setup +**Generated:** 2026-05-01 + +--- + +## Quick Navigation + +- [Docs Hub](../README.md) +- [Infrastructure](infrastructure.md) +- [Architecture](architecture.md) +- [Local Dev Setup](#1-local-development-setup) +- [Deployment](#2-deployment-optical-web-1) +- [Service Operations](#3-service-operations) +- [Troubleshooting](#4-troubleshooting) +- [Environment Variables](#5-environment-variables) + +## Agent Entry + +| Signal | Value | +|--------|-------| +| Purpose | Step-by-step procedures for running, deploying, and troubleshooting the platform | +| Read When | Local setup, deployment, restart, or incident diagnosis | +| Skip When | You need architecture understanding → architecture.md; inventory → infrastructure.md | +| Canonical | Yes | +| Next Docs | [Infrastructure](infrastructure.md), [Architecture](architecture.md) | +| Primary Sources | `scripts/run-local.sh`, `docker-compose.yml`, `.env.example` | + +--- + +## 1. Local Development Setup ### Prerequisites -| Requirement | Version | -|-------------|---------| -| Docker | 20.10+ | -| Docker Compose | V2 (bundled with Docker Desktop) | -| Node.js | 20+ | -| Python | 3.11+ (for local scripts only; app runs in Docker) | -| GCP credentials file | `./secrets/gcp-credentials.json` | +- Docker Desktop (with `docker compose` v2) +- Node.js 20+ and npm +- GCP credentials JSON at `secrets/gcp-credentials.json` +- `.env.local` file (copy from `.env.example`, fill secrets) -### First-Time Setup +### Backend (Docker) -| Step | Command / Action | -|------|-----------------| -| 1. Copy env template | `cp .env.prod.example .env.local` — fill in all values | -| 2. Copy frontend env | `cp frontend/.env.example frontend/.env.local` | -| 3. Place GCP credentials | Copy service account JSON to `./secrets/gcp-credentials.json` | -| 4. Set permissions | `chmod 600 ./secrets/gcp-credentials.json` | +```bash +# Start all backend services (API, workers, MongoDB, Redis) +./scripts/run-local.sh -### Starting the Local Environment +# Force image rebuild after code changes +./scripts/run-local.sh --rebuild -**Step 1 — Backend (Docker):** +# Stop all services +./scripts/run-local.sh --stop -`./scripts/run-local.sh` +# Restart +./scripts/run-local.sh --restart +``` -Services after start: +The script uses `docker-compose.yml` + `docker-compose.local.yml` with `.env.local`. -| Service | URL | -|---------|-----| -| API | http://localhost:8003 | -| API docs (Swagger) | http://localhost:8003/docs | -| MongoDB | mongodb://localhost:27017 | -| Redis | redis://localhost:6379 | +After startup: +- API: `http://localhost:8012` +- Swagger UI: `http://localhost:8012/docs` -**Step 2 — Frontend (Vite dev server, separate terminal):** +### Frontend (Vite dev server) -`cd frontend && npm install && npm run dev` +```bash +cd frontend +npm install +npm run dev +``` -Frontend URL: http://localhost:6001/video-accessibility +Frontend runs on `http://localhost:5173` by default. -### Common Local Commands +### Run Migrations -| Action | Command | -|--------|---------| -| Rebuild containers after code change | `./scripts/run-local.sh --rebuild` | -| Stop all services | `./scripts/run-local.sh --stop` | -| Tail all logs | `docker compose logs -f` | -| Tail API logs | `docker compose logs -f api` | -| Tail worker logs | `docker compose logs -f worker` | -| Restart a service | `docker compose restart api` | +```bash +docker exec -it accessible-video-api python migrate.py +``` -### Test Credentials (Local Only) +### Create Test Users -| Role | Email | Password | -|------|-------|---------| -| Admin | admin@example.com | admin | -| Reviewer | reviewer@example.com | reviewer | -| Client | client@example.com | client123 | - -Production uses Microsoft SSO — these credentials do not work in production. +```bash +docker exec -it accessible-video-api python create_test_users.py +``` --- -## Production Deployment +## 2. Deployment (optical-web-1) -**Server:** optical-web-1 -**Deploy path:** `/opt/video-accessibility/` -**URL:** https://ai-sandbox.oliver.solutions/video-accessibility/ +> **RULE:** Never SSH into optical-web-1 or run commands on it without explicit user instruction. -### Full Deployment (code + frontend) +### Deploy Script -Run on server (requires explicit user instruction — NEVER run via SSH without user approval): +```bash +./scripts/deploy-dev.sh +``` -`./scripts/full-deploy.sh` +### Frontend Build -This script: +```bash +./scripts/build-frontend.sh +``` -| Step | Action | -|------|--------| -| 1 | Pull latest code from git | -| 2 | Build Docker images | -| 3 | Restart containers | -| 4 | Build frontend bundle | -| 5 | Copy bundle to Apache webroot | -| 6 | Run DB seed if needed | +Builds the React SPA and copies `dist/` to the nginx serving directory. -### Frontend-Only Deployment +### Production Environment File -`./scripts/build-frontend.sh` +Production uses the `.env` file on optical-web-1. Key differences from `.env.example`: -Builds the React bundle and copies to `/var/www/html/video-accessibility/`. - -### Verification After Deploy - -| Check | Command / URL | -|-------|--------------| -| API health | `curl https://ai-sandbox.oliver.solutions/video-accessibility-back/health` | -| Container status | `docker compose ps` | -| Frontend loads | Visit https://ai-sandbox.oliver.solutions/video-accessibility | -| Worker running | `docker compose logs --tail=20 worker` | +| Variable | Production value | +|----------|-----------------| +| `APP_ENV` | `production` | +| `COOKIE_SECURE` | `true` | +| `COOKIE_DOMAIN` | `ai-sandbox.oliver.solutions` | +| All API keys | Real secret values | --- -## Database Operations +## 3. Service Operations -### Backup MongoDB +### View Logs -| Step | Command | -|------|---------| -| Dump to container | `docker compose exec mongodb mongodump --out=/data/backup` | -| Copy to host | `docker cp accessible-video-mongodb:/data/backup ./mongodb-backup-$(date +%Y%m%d)` | +```bash +docker logs accessible-video-api -f --tail=100 +docker logs accessible-video-worker -f --tail=100 +docker logs accessible-video-tts-worker -f --tail=100 +docker logs accessible-video-ffmpeg-worker -f --tail=100 +docker logs accessible-video-whisper-worker -f --tail=100 +``` -### Restore MongoDB +### Restart a Single Service -| Step | Command | -|------|---------| -| Copy to container | `docker cp ./mongodb-backup accessible-video-mongodb:/data/restore` | -| Restore | `docker compose exec mongodb mongorestore /data/restore` | +```bash +docker compose restart api +docker compose restart worker +docker compose restart tts-worker +docker compose restart ffmpeg-worker +docker compose restart whisper-worker +``` -### MongoDB Shell +### Restart All Services -`docker compose exec mongodb mongosh` +```bash +docker compose down && docker compose up -d +``` + +### Rebuild a Single Service + +```bash +docker compose build api && docker compose up -d api +docker compose build worker && docker compose up -d worker +``` + +### Check Running Services + +```bash +docker compose ps +``` + +### Check Queue Depths + +```bash +# Via API (requires admin token) +GET /api/v1/production/queue-stats + +# Via Redis CLI +docker exec -it accessible-video-redis redis-cli llen celery +``` --- -## Restarting Services +## 4. Troubleshooting -| Action | Command | -|--------|---------| -| Restart all | `docker compose restart` | -| Restart API only | `docker compose restart api` | -| Restart worker only | `docker compose restart worker` | -| Rebuild + restart one service | `docker compose up -d --build api` | +### TTS Worker Crash Loop (Memory) + +**Symptom:** `tts-worker` container restarts; OOM errors in logs. + +**Cause:** `TTS_WORKER_CONCURRENCY` × per-process memory exceeds available RAM. + +**Fix:** Lower `TTS_WORKER_CONCURRENCY` in `.env` (recommended: 2 for 512 MB containers), then: + +```bash +docker compose stop tts-worker +# edit .env: TTS_WORKER_CONCURRENCY=2 +docker compose up -d tts-worker +``` + +### Whisper Worker OOM + +**Symptom:** `whisper-worker` killed with exit code 137. + +**Cause:** Whisper `large-v3` requires ~4–6 GB RAM; container limit is 8 GB. + +**Fix:** Ensure host has sufficient free RAM, or switch to Cloud Run mode via `WHISPER_SERVICE_URL`. + +### Stuck Jobs + +**Symptom:** Job stays in `ingesting` or `ai_processing` indefinitely. + +**Steps:** +1. Check worker logs for errors +2. Admin API: `POST /api/v1/admin/maintenance/reprocess-job/{job_id}` +3. Or: `POST /api/v1/jobs/{job_id}/retry` + +### MongoDB Connection Failure + +**Symptom:** API returns 500; logs show `ServerSelectionTimeoutError`. + +**Steps:** +1. `docker compose ps` — check mongodb container status +2. `docker logs accessible-video-mongodb --tail=50` +3. Confirm `MONGODB_URI` in `.env` matches the running container + +### Redis Connection Failure + +**Symptom:** Celery tasks not executing; `redis.exceptions.ConnectionError` in logs. + +**Steps:** +1. `docker exec -it accessible-video-redis redis-cli ping` — should return `PONG` +2. `docker compose restart redis` +3. `docker compose restart worker tts-worker ffmpeg-worker whisper-worker` + +### GCS Access Denied + +**Symptom:** `403 Forbidden` from GCS; files not uploading. + +**Steps:** +1. Verify `secrets/gcp-credentials.json` exists and is bind-mounted +2. Confirm service account has `Storage Object Admin` on `GCS_BUCKET` +3. Check `GCP_PROJECT_ID` and `GCS_BUCKET` in `.env` + +### Celery Worker Not Processing Queue + +**Symptom:** Jobs queued but workers idle. + +**Steps:** +1. `docker compose ps` — check worker containers running +2. Check worker logs for import errors at startup +3. Verify `CELERY_BROKER_URL` resolves to Redis within the compose network --- -## Updating Application +### WebSocket Disconnects / Reconnect Storms (optical-web-1) -| Step | Command | -|------|---------| -| Pull code | `git pull origin main` | -| Full redeploy | `./scripts/full-deploy.sh` | -| Frontend only | `./scripts/build-frontend.sh` | +**Symptom:** Users experience frequent WebSocket disconnections followed by rapid reconnect attempts visible in browser DevTools Network tab. + +**Root cause:** Apache `mod_proxy_wstunnel` on optical-web-1 has a `ProxyTimeout` that drops idle WebSocket connections. The client ping interval (20 s) and server keepalive frame (20 s) are designed to prevent this, but only if Apache's timeout is above 20 s. + +**Recommended Apache config** (verify with DevOps before applying): + +```apache +# In the VirtualHost block for the API +ProxyTimeout 60 +``` + +> **Do not set ProxyTimeout below 30 s.** The Mod Comms 2026-03-18 incident showed that 25 s was insufficient through mod_proxy_wstunnel — the idle timer fires on the _proxy_ side before the client ping arrives. 60 s provides a comfortable margin above the 20 s bidirectional keepalive cadence. + +**Verification after change:** +1. Open DevTools → Network → WS tab +2. Connect to any job and let it sit idle for 2 minutes +3. Confirm no `close` frames and no reconnect attempts appear --- -## Linting and Type Checking +## 5. Environment Variables -| Check | Command | Must pass before deploy | -|-------|---------|------------------------| -| Backend lint | `cd backend && ruff check .` | Yes | -| Backend type check | `docker compose exec api python -m mypy app/` | Yes | -| Frontend lint | `cd frontend && npm run lint` | Yes | -| Frontend type check | `cd frontend && npm run type-check` | Yes (currently 0 errors) | +Copy from `.env.example`. All variables are required unless marked optional. + +| Variable | Default | Required | Description | +|----------|---------|----------|-------------| +| `APP_ENV` | `dev` | Yes | `dev` or `production` | +| `API_BASE_URL` | — | Yes | Public API base URL | +| `JWT_SECRET` | — | **Yes** | Random secret; rotation invalidates all sessions | +| `JWT_ALG` | `HS256` | No | JWT signing algorithm | +| `JWT_ACCESS_TTL_MIN` | `240` | No | Access token TTL (minutes) | +| `JWT_REFRESH_TTL_DAYS` | `7` | No | Refresh token TTL (days) | +| `COOKIE_DOMAIN` | `ai-sandbox.oliver.solutions` | Yes | Refresh cookie domain | +| `COOKIE_SECURE` | `true` | No | Set `false` for local HTTP | +| `COOKIE_SAMESITE` | `Lax` | No | | +| `MONGODB_URI` | — | Yes | MongoDB connection string | +| `MONGODB_DB` | `accessible_video` | No | Database name | +| `REDIS_URL` | `redis://redis:6379/0` | Yes | | +| `CELERY_BROKER_URL` | `redis://redis:6379/0` | Yes | Same as REDIS_URL | +| `CELERY_RESULT_BACKEND` | `redis://redis:6379/0` | Yes | | +| `GCP_PROJECT_ID` | — | Yes | GCP project ID | +| `GCS_BUCKET` | `accessible-video` | Yes | GCS bucket name | +| `GOOGLE_APPLICATION_CREDENTIALS` | `/secrets/gcp-credentials.json` | Yes | Path to service account JSON | +| `GEMINI_API_KEY` | — | Yes | Gemini 2.5 Pro API key | +| `TRANSLATE_API_KEY` | — | No | Google Translate API key | +| `ELEVENLABS_API_KEY` | — | No | ElevenLabs API key | +| `GOOGLE_TTS_CREDENTIALS` | `/secrets/gcp-credentials.json` | No | Separate TTS credentials if needed | +| `SENDGRID_API_KEY` | — | No | SendGrid API key | +| `EMAIL_FROM` | `noreply@ai-sandbox.oliver.solutions` | No | Sender address | +| `CLIENT_BASE_URL` | — | No | Frontend URL for email links | +| `AZURE_CLIENT_ID` | — | No | Microsoft SSO client ID | +| `AZURE_AUTHORITY` | — | No | Microsoft tenant authority URL | +| `AZURE_REDIRECT_URI` | — | No | Microsoft OIDC redirect URI | +| `CORS_ORIGINS` | localhost variants | Yes | Comma-separated allowed origins | +| `SENTRY_DSN` | — | No | Sentry DSN | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | No | OpenTelemetry collector endpoint | +| `COST_TRACKER_BASE_URL` | — | No | AI cost tracker API URL | +| `COST_TRACKER_API_KEY` | — | No | AI cost tracker API key | +| `COST_TRACKER_SOURCE_APP` | `video-accessibility` | No | App identifier | +| `COST_TRACKER_ENABLED` | `true` | No | Enable/disable cost tracking | +| `WORKER_CONCURRENCY` | `8` | No | General worker concurrency | +| `TTS_WORKER_CONCURRENCY` | `2` | No | TTS worker concurrency | +| `FFMPEG_WORKER_CONCURRENCY` | `1` | No | FFmpeg worker concurrency | +| `WHISPER_WORKER_CONCURRENCY` | `1` | No | Whisper worker concurrency | +| `FFMPEG_SERVICE_URL` | — | No | Cloud Run FFmpeg service URL | +| `WHISPER_SERVICE_URL` | — | No | Cloud Run Whisper service URL | +| `WHISPER_MODEL` | `medium` | No | Whisper model size | +| `USE_CELERY_FALLBACK` | `false` | No | Force local Celery instead of Cloud Run | --- -## Monitoring +## 6. Rollback -| Tool | Access | Purpose | -|------|--------|---------| -| Docker stats | `docker stats` | Container CPU/memory usage | -| API logs | `docker compose logs -f api` | Request errors | -| Worker logs | `docker compose logs -f worker` | Task errors | -| Sentry | sentry.io | Exception capture + stack traces | -| Prometheus | localhost:8001/metrics | Metrics (internal only) | +### Code Rollback ---- +Check out the previous commit and rebuild: -## Troubleshooting +```bash +git log --oneline -10 +git checkout +docker compose build && docker compose up -d +``` -| Symptom | Check | Fix | -|---------|-------|-----| -| 502 Bad Gateway on API | `docker compose ps api` + logs | Restart: `docker compose restart api` | -| Frontend 404 | `ls /var/www/html/video-accessibility/` | Rebuild: `./scripts/build-frontend.sh` | -| WebSocket fails | `apache2ctl -M | grep proxy_wstunnel` | `sudo a2enmod proxy_wstunnel && sudo systemctl restart apache2` | -| Worker not processing | `docker compose logs -f worker` | Check Redis URL + GCP credentials mount | -| Upload fails (GCS) | Test credentials in container | Check `./secrets/gcp-credentials.json` exists + permissions | -| MongoDB auth fails | Check `MONGODB_URI` env var | Verify Atlas connection string | +### JWT Secret Rotation ---- - -## Apache Configuration - -Required modules: - -`sudo a2enmod rewrite proxy proxy_http proxy_wstunnel headers && sudo systemctl restart apache2` - -Config file: `/etc/apache2/sites-available/ai-sandbox.oliver.solutions-ssl.conf` - -Key directives needed: - -| Directive | Purpose | -|-----------|---------| -| `Alias /video-accessibility /var/www/html/video-accessibility` | Serve frontend | -| `ProxyPass /video-accessibility-back http://localhost:8000` | Proxy API | -| `RewriteRule ^ /video-accessibility/index.html [L]` | SPA routing | -| `RewriteEngine On` with WebSocket rules | WS proxy | +1. Generate: `openssl rand -hex 32` +2. Update `JWT_SECRET` in `.env` +3. `docker compose restart api` +4. All existing sessions are invalidated — users must re-login --- ## Maintenance -**Update triggers:** New deploy script, new service port, new server. -**Verification:** All commands in this runbook execute without error on a clean checkout. Test credentials are not committed to production env files. +**Last Updated:** 2026-05-01 - +**Update Triggers:** +- New script added to `scripts/` +- Deployment target changes +- New environment variable required +- New Docker service added + +**Verification:** +- [ ] `./scripts/run-local.sh` flags match actual script +- [ ] Environment variable table complete vs `.env.example` +- [ ] Worker env var names match `docker-compose.yml` +- [ ] Troubleshooting container names match compose service names diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23f943d..f45c175 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { BriefsList } from './routes/briefs/BriefsList'; import { NewBrief } from './routes/briefs/NewBrief'; import { BriefDetail } from './routes/briefs/BriefDetail'; import { LinguistQueue } from './routes/jobs/LinguistQueue'; +import { ReviewerQueue } from './routes/jobs/ReviewerQueue'; import { Downloads } from './routes/Downloads'; import { ShareView } from './routes/ShareView'; import { AcceptInvite } from './routes/AcceptInvite'; @@ -195,17 +196,23 @@ function AppContent() { } /> - + + + } /> - + + + } /> - + + + } /> } /> + + + + + + } /> diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 529cc4a..bbe07c6 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -74,6 +74,12 @@ export function Sidebar({ onMobileClose }: SidebarProps) { roles: ['linguist', 'reviewer', 'production', 'admin'], badge: qcBadge || undefined, }, + { + label: 'Reviewer Queue', + href: '/qc/reviewer-queue', + icon: '🔎', + roles: ['reviewer', 'admin'], + }, { label: 'QC Review', href: '/admin/qc', diff --git a/frontend/src/components/VttEditor/VttEditor.tsx b/frontend/src/components/VttEditor/VttEditor.tsx index 240257c..dab9b9b 100644 --- a/frontend/src/components/VttEditor/VttEditor.tsx +++ b/frontend/src/components/VttEditor/VttEditor.tsx @@ -90,11 +90,17 @@ interface VttEditorProps { readOnly?: boolean; glossaryTerms?: GlossaryTerm[]; language?: string; - insertAtTimeMs?: number | null; // when set, auto-insert a cue at/near this timestamp - onInsertAtTimeDone?: () => void; // callback to clear insertAtTimeMs after insert + insertAtTimeMs?: number | null; + onInsertAtTimeDone?: () => void; + /** True when this editor is displaying the source language VTT */ + isSourceLanguage?: boolean; + /** Number of target languages that would have QC reset on source save */ + affectedLanguagesCount?: number; + /** Called when user clicks "Re-translate all" — triggers retranslate_languages=true save */ + onRetranslate?: () => Promise; } -export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en', insertAtTimeMs, onInsertAtTimeDone }: VttEditorProps) { +export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en', insertAtTimeMs, onInsertAtTimeDone, isSourceLanguage = false, affectedLanguagesCount = 0, onRetranslate }: VttEditorProps) { const [cues, setCues] = useState([]); const [errors, setErrors] = useState([]); const [editingCue, setEditingCue] = useState(null); @@ -275,8 +281,37 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu const totalErrorCount = Array.from(cueErrors.values()).reduce((sum, errs) => sum + errs.length, 0); + const [retranslating, setRetranslating] = useState(false); + + const handleRetranslate = async () => { + if (!onRetranslate) return; + setRetranslating(true); + try { + await onRetranslate(); + } finally { + setRetranslating(false); + } + }; + return (
+ {/* Source-language invalidation banner */} + {isSourceLanguage && !readOnly && affectedLanguagesCount > 0 && ( +
+

+ Saving will reset QC for {affectedLanguagesCount} translated {affectedLanguagesCount === 1 ? 'language' : 'languages'}. +

+ {onRetranslate && ( + + )} +
+ )} {/* Header */}
diff --git a/frontend/src/contexts/GlobalWebSocketContext.tsx b/frontend/src/contexts/GlobalWebSocketContext.tsx index 6ca99f3..b4f2ce4 100644 --- a/frontend/src/contexts/GlobalWebSocketContext.tsx +++ b/frontend/src/contexts/GlobalWebSocketContext.tsx @@ -20,24 +20,30 @@ export function GlobalWebSocketProvider({ children }: { children: ReactNode }) { const handleStatusUpdate = useCallback((update: JobStatusUpdate) => { // Use job_title from the update, or fallback to generic message const jobTitle = update.job_title || 'Job'; - + const { message, type, showToast } = getStatusMessageConfig( update.status, jobTitle, update.message ); - + if (showToast) { // Pass job_id and job_title to toast for notification integration toast[type](message, undefined, update.job_id, update.job_title); } }, [toast]); + const handleTerminalClose = useCallback((code: number, reason: string) => { + const label = reason || (code === 4403 ? 'Org access denied' : code === 4001 ? 'User not found' : 'Access denied'); + toast.error(`Connection closed: ${label}`); + }, [toast]); + // Use the existing WebSocket hook for global job list updates (no jobId) const { connectionStatus, reconnect, disconnect } = useJobStatusWebSocket(undefined, { debug: false, autoReconnect: true, - onStatusUpdate: handleStatusUpdate + onStatusUpdate: handleStatusUpdate, + onTerminalClose: handleTerminalClose, }); const contextValue: GlobalWebSocketContextType = { diff --git a/frontend/src/hooks/useJobStatusWebSocket.ts b/frontend/src/hooks/useJobStatusWebSocket.ts index 9385b22..15759f7 100644 --- a/frontend/src/hooks/useJobStatusWebSocket.ts +++ b/frontend/src/hooks/useJobStatusWebSocket.ts @@ -72,6 +72,12 @@ interface UseJobStatusWebSocketOptions { * Raw message handler — called for every parsed WS message regardless of type */ onRawMessage?: (msg: WebSocketMessage) => void; + + /** + * Called when the WebSocket closes with a terminal code (1000, 4001, 4003, 4004, 4403). + * Use this to surface the close reason to the user. + */ + onTerminalClose?: (code: number, reason: string) => void; } interface UseJobStatusWebSocketReturn { @@ -112,6 +118,7 @@ export function useJobStatusWebSocket( onStatusUpdate, onConnectionChange, onRawMessage, + onTerminalClose, } = options; const queryClient = useQueryClient(); @@ -280,6 +287,9 @@ export function useJobStatusWebSocket( // Terminal codes = permanent auth/permission failure; do NOT retry. // 4001=user not found, 4003=role denied, 4004=job not found, 4403=org denied. const isTerminal = [1000, 4001, 4003, 4004, 4403].includes(event.code); + if (isTerminal && event.code !== 1000 && onTerminalClose) { + onTerminalClose(event.code, event.reason); + } // Attempt to reconnect if enabled and component is still mounted if ( @@ -298,7 +308,7 @@ export function useJobStatusWebSocket( } }, delay); } - }, [log, autoReconnect, maxReconnectAttempts, reconnectDelay, handleConnectionChange]); + }, [log, autoReconnect, maxReconnectAttempts, reconnectDelay, handleConnectionChange, onTerminalClose]); const handleError = useCallback((error: Event) => { console.error('WebSocket error:', error); diff --git a/frontend/src/hooks/useUsers.ts b/frontend/src/hooks/useUsers.ts index 3f63643..cfefb0b 100644 --- a/frontend/src/hooks/useUsers.ts +++ b/frontend/src/hooks/useUsers.ts @@ -11,6 +11,7 @@ export function useUsers(filters?: { size?: number; role?: string; active_only?: boolean; + org_id?: string; }) { return useQuery({ queryKey: ['users', filters], diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fdcc012..2ee122f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -416,12 +416,14 @@ class ApiClient { size?: number; role?: string; active_only?: boolean; + org_id?: string; }): Promise { const params = new URLSearchParams(); if (filters?.page) params.append('page', filters.page.toString()); if (filters?.size) params.append('size', filters.size.toString()); if (filters?.role) params.append('role', filters.role); if (filters?.active_only !== undefined) params.append('active_only', filters.active_only.toString()); + if (filters?.org_id) params.append('org_id', filters.org_id); const response = await this.client.get(`/admin/users?${params.toString()}`); return response.data; diff --git a/frontend/src/routes/Dashboard.tsx b/frontend/src/routes/Dashboard.tsx index dfb9361..11fcbaa 100644 --- a/frontend/src/routes/Dashboard.tsx +++ b/frontend/src/routes/Dashboard.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { useAuthStore } from '../lib/auth'; -import { useJobs, useProductionQueueStats } from '../hooks/useJob'; +import { useJobs, useProductionQueueStats, useBriefs } from '../hooks/useJob'; import { StatusBadge } from '../components/StatusBadge'; import type { Job } from '../types/api'; @@ -12,9 +12,14 @@ export function Dashboard() { enabled: isAuthenticated && !!user }); const { data: queueStats } = useProductionQueueStats(); + const { data: briefsData } = useBriefs(); const jobs = jobsData?.jobs || []; + const ACTIVE_STATUSES = ['created', 'ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video', 'rendering_qc', 'pending_qc', 'qc_feedback', 'pending_final_review']; + const now = Date.now(); + const MS_24H = 24 * 60 * 60 * 1000; + const stats = { total: jobs.length, pending: jobs.filter((j: Job) => ['created', 'ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video', 'rendering_qc'].includes(j.status)).length, @@ -23,6 +28,10 @@ export function Dashboard() { finalReview: jobs.filter((j: Job) => j.status === 'pending_final_review').length, aiProcessing: jobs.filter((j: Job) => ['ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video'].includes(j.status)).length, failed: jobs.filter((j: Job) => ['tts_failed', 'render_failed'].includes(j.status)).length, + overdue: jobs.filter((j: Job) => j.deadline && new Date(j.deadline).getTime() < now && !['completed', 'rejected'].includes(j.status)).length, + stuck: jobs.filter((j: Job) => ACTIVE_STATUSES.includes(j.status) && (now - new Date(j.updated_at).getTime()) > MS_24H).length, + awaitingUpload: (briefsData?.briefs ?? []).filter(b => b.status === 'submitted').length, + pendingQcHandoff: jobs.filter((j: Job) => j.status === 'ai_processing' && !(j.language_qc && Object.keys(j.language_qc).length > 0)).length, }; const renderRoleSpecificContent = () => { @@ -88,7 +97,7 @@ export function Dashboard() { case 'project_manager': return ( -
+

Upload now →

+ + 0 ? 'bg-gradient-to-br from-red-500 to-rose-700' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`} + > +
+
+ {stats.overdue > 0 ? '⏰' : '✓'} +
+

Overdue

+
+

{stats.overdue}

+

past deadline, not completed

+

View overdue →

+ + + 0 ? 'bg-gradient-to-br from-yellow-500 to-amber-700' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`} + > +
+
+ {stats.stuck > 0 ? '🐢' : '✓'} +
+

Stuck > 24h

+
+

{stats.stuck}

+

no progress in over 24 hours

+

Investigate →

+
); @@ -162,6 +201,52 @@ export function Dashboard() {
+
0 ? 'from-violet-500 to-purple-700' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}> +
+
+ 📥 +
+

Awaiting Upload

+
+

+ {stats.awaitingUpload} submitted brief{stats.awaitingUpload !== 1 ? 's' : ''} need video +

+

+ Briefs approved by client but video not yet uploaded. +

+ {stats.awaitingUpload > 0 && ( + + View briefs → + + )} +
+ +
0 ? 'from-orange-400 to-amber-600' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}> +
+
+ 🔄 +
+

Pending QC Handoff

+
+

+ {stats.pendingQcHandoff} job{stats.pendingQcHandoff !== 1 ? 's' : ''} awaiting linguist +

+

+ AI processing complete but no linguist assigned yet. +

+ {stats.pendingQcHandoff > 0 && ( + + Assign now → + + )} +
+
0 ? 'from-red-500 to-red-700' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}>
diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index b4d253e..dc44951 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -1750,6 +1750,23 @@ export function QCDetail() { readOnly={isProcessing} glossaryTerms={glossaryTerms} language={selectedLanguage} + isSourceLanguage={selectedLanguage === sourceLanguage} + affectedLanguagesCount={ + selectedLanguage === sourceLanguage + ? Object.entries(langQcMap).filter( + ([lang, state]) => + lang !== sourceLanguage && + ['approved', 'pending_review', 'in_review'].includes( + (state as { status?: string }).status ?? '' + ) + ).length + : 0 + } + onRetranslate={ + selectedLanguage === sourceLanguage + ? async () => { await _doSaveVtt(true, captionsVtt || undefined, adVtt || undefined); } + : undefined + } />
@@ -1783,6 +1800,23 @@ export function QCDetail() { language={selectedLanguage} insertAtTimeMs={adInsertAtTimeMs} onInsertAtTimeDone={() => setAdInsertAtTimeMs(null)} + isSourceLanguage={selectedLanguage === sourceLanguage} + affectedLanguagesCount={ + selectedLanguage === sourceLanguage + ? Object.entries(langQcMap).filter( + ([lang, state]) => + lang !== sourceLanguage && + ['approved', 'pending_review', 'in_review'].includes( + (state as { status?: string }).status ?? '' + ) + ).length + : 0 + } + onRetranslate={ + selectedLanguage === sourceLanguage + ? async () => { await _doSaveVtt(true, captionsVtt || undefined, adVtt || undefined); } + : undefined + } />
diff --git a/frontend/src/routes/admin/UserList.tsx b/frontend/src/routes/admin/UserList.tsx index 0ab8a3c..8f0569e 100644 --- a/frontend/src/routes/admin/UserList.tsx +++ b/frontend/src/routes/admin/UserList.tsx @@ -7,6 +7,7 @@ import { useResetUserPassword, useCreateUser, } from '../../hooks/useUsers'; +import { useOrganizations } from '../../hooks/useClients'; import { useToastContext } from '../../contexts/ToastContext'; import type { UserRole, CreateUserRequest } from '../../types/api'; @@ -14,14 +15,18 @@ export function UserList() { const [page, setPage] = useState(1); const [roleFilter, setRoleFilter] = useState(''); const [activeOnly, setActiveOnly] = useState(true); + const [orgFilter, setOrgFilter] = useState(''); const [showCreateModal, setShowCreateModal] = useState(false); const toast = useToastContext(); + const { data: orgs } = useOrganizations(); + const { data: usersResponse, isLoading, error } = useUsers({ page, size: 20, role: roleFilter || undefined, active_only: activeOnly, + org_id: orgFilter || undefined, }); const deactivateUserMutation = useDeactivateUser(); @@ -140,6 +145,25 @@ export function UserList() {
+ {orgs && orgs.length > 0 && ( +
+ + +
+ )} +
= { rejected: 'Rejected', }; +function SortArrow({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) { + if (!active) return ; + return {dir === 'asc' ? '↑' : '↓'}; +} + function QueueRow({ item, role }: { item: QueueItem; role: 'linguist' | 'reviewer' }) { const navigate = useNavigate(); const qcStatus = item.lang_qc_status as LanguageQCStatus; @@ -79,9 +84,15 @@ function QueueRow({ item, role }: { item: QueueItem; role: 'linguist' | 'reviewe ); } -export function LinguistQueue() { - const [activeRole, setActiveRole] = useState<'linguist' | 'reviewer'>('linguist'); +interface LinguistQueueProps { + /** Lock the queue to a specific role (hides the role toggle). */ + defaultRole?: 'linguist' | 'reviewer'; +} + +export function LinguistQueue({ defaultRole }: LinguistQueueProps = {}) { + const [activeRole, setActiveRole] = useState<'linguist' | 'reviewer'>(defaultRole ?? 'linguist'); const [activeTab, setActiveTab] = useState('all'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const { data, isLoading, refetch } = useQuery({ queryKey: ['linguist-queue', activeRole, activeTab], @@ -92,13 +103,24 @@ export function LinguistQueue() { refetchInterval: 30_000, }); - const items = data?.items ?? []; + const items = useMemo(() => { + const raw = data?.items ?? []; + return [...raw].sort((a, b) => { + const ta = a.assigned_at ? new Date(a.assigned_at).getTime() : 0; + const tb = b.assigned_at ? new Date(b.assigned_at).getTime() : 0; + return sortDir === 'asc' ? ta - tb : tb - ta; + }); + }, [data, sortDir]); + + const toggleSort = () => setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); return (
-

My QC Queue

+

+ {defaultRole === 'reviewer' ? 'Reviewer Queue' : 'My QC Queue'} +

Languages assigned to you for quality control

- {/* Role toggle */} -
- {(['linguist', 'reviewer'] as const).map(role => ( - - ))} -
+ {/* Role toggle — hidden when role is locked */} + {!defaultRole && ( +
+ {(['linguist', 'reviewer'] as const).map(role => ( + + ))} +
+ )} {/* Status tabs */}
@@ -169,7 +193,12 @@ export function LinguistQueue() { Lang QC Status Job Status - Assigned + + Assigned + {activeRole === 'reviewer' && ( Reviewed )} diff --git a/frontend/src/routes/jobs/ReviewerQueue.tsx b/frontend/src/routes/jobs/ReviewerQueue.tsx new file mode 100644 index 0000000..8fa0dae --- /dev/null +++ b/frontend/src/routes/jobs/ReviewerQueue.tsx @@ -0,0 +1,5 @@ +import { LinguistQueue } from './LinguistQueue'; + +export function ReviewerQueue() { + return ; +}