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:
parent
b427ee9f49
commit
32b12ff0a6
14 changed files with 579 additions and 185 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ~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 <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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function useUsers(filters?: {
|
|||
size?: number;
|
||||
role?: string;
|
||||
active_only?: boolean;
|
||||
org_id?: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ['users', filters],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 > 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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
5
frontend/src/routes/jobs/ReviewerQueue.tsx
Normal file
5
frontend/src/routes/jobs/ReviewerQueue.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { LinguistQueue } from './LinguistQueue';
|
||||
|
||||
export function ReviewerQueue() {
|
||||
return <LinguistQueue defaultRole="reviewer" />;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue