feat(ux): P2 role UX — reviewer queue, dashboard widgets, org filter, WS toast

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-01 11:58:29 +01:00
parent b427ee9f49
commit 32b12ff0a6
14 changed files with 579 additions and 185 deletions

View file

@ -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()

View file

@ -1,213 +1,356 @@
# Runbook — Accessible Video Processing Platform
<!-- SCOPE: runbook | owner: ln-115 | generated: 2026-04-29 -->
<!-- SCOPE: Operational procedures — local dev setup, deployment, service restart, troubleshooting, rollback. No architecture rationale (see architecture.md). -->
<!-- DOC_KIND: how-to -->
<!-- DOC_ROLE: canonical -->
<!-- READ_WHEN: Read when setting up locally, deploying to optical-web-1, restarting services, or diagnosing an incident. -->
<!-- SKIP_WHEN: Skip when you need architecture understanding → architecture.md; infrastructure inventory → infrastructure.md. -->
<!-- PRIMARY_SOURCES: scripts/run-local.sh, docker-compose.yml, .env.example, scripts/deploy-dev.sh -->
## 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 ~46 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 <previous-commit>
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
<!-- END SCOPE: runbook -->
**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

View file

@ -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() {
} />
<Route path="/briefs" element={
<AuthenticatedRoute>
<BriefsList />
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<BriefsList />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/briefs/new" element={
<AuthenticatedRoute>
<NewBrief />
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<NewBrief />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/briefs/:id" element={
<AuthenticatedRoute>
<BriefDetail />
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<BriefDetail />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/qc/queue" element={
@ -215,6 +222,13 @@ function AppContent() {
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/qc/reviewer-queue" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'admin']}>
<ReviewerQueue />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/downloads/:id" element={
<AuthenticatedRoute>
<Downloads />

View file

@ -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',

View file

@ -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<void>;
}
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<VTTCue[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [editingCue, setEditingCue] = useState<number | null>(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 (
<div className="border border-gray-300 rounded-lg">
{/* Source-language invalidation banner */}
{isSourceLanguage && !readOnly && affectedLanguagesCount > 0 && (
<div className="flex items-center justify-between gap-3 px-4 py-2.5 bg-amber-50 border-b border-amber-200 rounded-t-lg">
<p className="text-sm text-amber-800">
Saving will reset QC for <strong>{affectedLanguagesCount}</strong> translated {affectedLanguagesCount === 1 ? 'language' : 'languages'}.
</p>
{onRetranslate && (
<button
onClick={handleRetranslate}
disabled={retranslating}
className="flex-shrink-0 px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
>
{retranslating ? 'Translating…' : 'Re-translate all'}
</button>
)}
</div>
)}
{/* Header */}
<div className="bg-gray-50 px-4 py-3 border-b border-gray-300">
<div className="flex items-center justify-between">

View file

@ -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 = {

View file

@ -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);

View file

@ -11,6 +11,7 @@ export function useUsers(filters?: {
size?: number;
role?: string;
active_only?: boolean;
org_id?: string;
}) {
return useQuery({
queryKey: ['users', filters],

View file

@ -416,12 +416,14 @@ class ApiClient {
size?: number;
role?: string;
active_only?: boolean;
org_id?: string;
}): Promise<UserListResponse> {
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;

View file

@ -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 (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Link
to="/admin/final"
className="group bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5"
@ -134,6 +143,36 @@ export function Dashboard() {
</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Upload now </p>
</Link>
<Link
to="/jobs?overdue=true"
className={`group rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5 ${stats.overdue > 0 ? 'bg-gradient-to-br from-red-500 to-rose-700' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}
>
<div className="flex items-center mb-3">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
<span className="text-xl">{stats.overdue > 0 ? '⏰' : '✓'}</span>
</div>
<h3 className="text-lg font-bold">Overdue</h3>
</div>
<p className="text-3xl font-bold mb-1">{stats.overdue}</p>
<p className="text-white/80 text-sm mb-4">past deadline, not completed</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">View overdue </p>
</Link>
<Link
to="/jobs?stuck=true"
className={`group rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5 ${stats.stuck > 0 ? 'bg-gradient-to-br from-yellow-500 to-amber-700' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}
>
<div className="flex items-center mb-3">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
<span className="text-xl">{stats.stuck > 0 ? '🐢' : '✓'}</span>
</div>
<h3 className="text-lg font-bold">Stuck &gt; 24h</h3>
</div>
<p className="text-3xl font-bold mb-1">{stats.stuck}</p>
<p className="text-white/80 text-sm mb-4">no progress in over 24 hours</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Investigate </p>
</Link>
</div>
);
@ -162,6 +201,52 @@ export function Dashboard() {
</Link>
</div>
<div className={`bg-gradient-to-br ${stats.awaitingUpload > 0 ? 'from-violet-500 to-purple-700' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}>
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center mr-4">
<span className="text-2xl">📥</span>
</div>
<h2 className="text-2xl font-bold">Awaiting Upload</h2>
</div>
<p className="text-white/80 mb-2 text-lg font-semibold">
{stats.awaitingUpload} submitted brief{stats.awaitingUpload !== 1 ? 's' : ''} need video
</p>
<p className="text-white/70 mb-6 leading-relaxed">
Briefs approved by client but video not yet uploaded.
</p>
{stats.awaitingUpload > 0 && (
<Link
to="/briefs?status=submitted"
className="inline-flex items-center bg-white text-purple-600 px-6 py-3 rounded-lg hover:bg-purple-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
View briefs
</Link>
)}
</div>
<div className={`bg-gradient-to-br ${stats.pendingQcHandoff > 0 ? 'from-orange-400 to-amber-600' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}>
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center mr-4">
<span className="text-2xl">🔄</span>
</div>
<h2 className="text-2xl font-bold">Pending QC Handoff</h2>
</div>
<p className="text-white/80 mb-2 text-lg font-semibold">
{stats.pendingQcHandoff} job{stats.pendingQcHandoff !== 1 ? 's' : ''} awaiting linguist
</p>
<p className="text-white/70 mb-6 leading-relaxed">
AI processing complete but no linguist assigned yet.
</p>
{stats.pendingQcHandoff > 0 && (
<Link
to="/jobs?status=ai_processing"
className="inline-flex items-center bg-white text-amber-600 px-6 py-3 rounded-lg hover:bg-amber-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Assign now
</Link>
)}
</div>
<div className={`bg-gradient-to-br ${stats.failed > 0 ? 'from-red-500 to-red-700' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}>
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center mr-4">

View file

@ -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
}
/>
<div className="mt-2">
<VttDiffView jobId={id!} lang={selectedLanguage} kind="captions" />
@ -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
}
/>
<div className="mt-2">
<VttDiffView jobId={id!} lang={selectedLanguage} kind="ad" />

View file

@ -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<string>('');
const [activeOnly, setActiveOnly] = useState(true);
const [orgFilter, setOrgFilter] = useState<string>('');
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() {
</select>
</div>
{orgs && orgs.length > 0 && (
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">Org:</label>
<select
value={orgFilter}
onChange={(e) => {
setOrgFilter(e.target.value);
setPage(1);
}}
className="text-sm border border-gray-300 rounded px-3 py-1.5"
>
<option value="">All Orgs</option>
{orgs.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
)}
<div className="flex items-center space-x-2">
<input
type="checkbox"

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
@ -41,6 +41,11 @@ const JOB_STATUS_LABEL: Record<string, string> = {
rejected: 'Rejected',
};
function SortArrow({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
if (!active) return <span className="ml-1 text-gray-300"></span>;
return <span className="ml-1">{dir === 'asc' ? '↑' : '↓'}</span>;
}
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<LanguageQCStatus | 'all'>('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 (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My QC Queue</h1>
<h1 className="text-2xl font-semibold text-gray-900">
{defaultRole === 'reviewer' ? 'Reviewer Queue' : 'My QC Queue'}
</h1>
<p className="text-sm text-gray-500 mt-1">Languages assigned to you for quality control</p>
</div>
<button
@ -109,22 +131,24 @@ export function LinguistQueue() {
</button>
</div>
{/* Role toggle */}
<div className="flex gap-2 mb-5">
{(['linguist', 'reviewer'] as const).map(role => (
<button
key={role}
onClick={() => { setActiveRole(role); setActiveTab('all'); }}
className={`px-4 py-1.5 text-sm font-medium rounded-full border transition-colors ${
activeRole === role
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-800'
}`}
>
As {role}
</button>
))}
</div>
{/* Role toggle — hidden when role is locked */}
{!defaultRole && (
<div className="flex gap-2 mb-5">
{(['linguist', 'reviewer'] as const).map(role => (
<button
key={role}
onClick={() => { setActiveRole(role); setActiveTab('all'); }}
className={`px-4 py-1.5 text-sm font-medium rounded-full border transition-colors ${
activeRole === role
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-800'
}`}
>
As {role}
</button>
))}
</div>
)}
{/* Status tabs */}
<div className="flex gap-1 mb-4 border-b border-gray-200 overflow-x-auto">
@ -169,7 +193,12 @@ export function LinguistQueue() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Lang</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">QC Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Job Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Assigned</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer select-none hover:text-gray-700"
onClick={toggleSort}
>
Assigned <SortArrow active dir={sortDir} />
</th>
{activeRole === 'reviewer' && (
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reviewed</th>
)}

View file

@ -0,0 +1,5 @@
import { LinguistQueue } from './LinguistQueue';
export function ReviewerQueue() {
return <LinguistQueue defaultRole="reviewer" />;
}