feat(ux): T-2/PR-7/PR-8 — status color helper, queue stats widget, upload-final-VTT override
T-2: Extract getJobStatusColor() into utils/jobStatusMessages.ts; StatusBadge now uses the
shared helper (single source of truth for badge colors).
PR-7: GET /admin/production/queue-stats — returns Celery queue depths via Redis LLEN.
Production dashboard shows a live panel (10s refresh) with per-queue task counts.
PR-8: POST /admin/production/jobs/{id}/upload-final-vtt — Production/Admin can upload a
hand-crafted VTT to bypass AI, writing to GCS and advancing the job to PENDING_QC.
Upload modal added to FailuresList with language + type (captions/ad) selectors.
docker-compose.optical-dev.yml: enable USE_CELERY_FALLBACK=true, set worker replicas=1
for all pipeline workers (ffmpeg/tts/whisper) with WORKER_CONCURRENCY=2 so the full
pipeline runs on the 2-CPU optical-dev server until Cloud Run VPC Connector is ready.
Fix: remove unused effectiveMs variable in TimelinePreview (TS6133).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e4b350cd7d
commit
c1948ea198
9 changed files with 368 additions and 93 deletions
|
|
@ -1,22 +1,31 @@
|
|||
"""Admin production endpoints: failure dashboard and bulk retry."""
|
||||
"""Admin production endpoints: failure dashboard, bulk retry, queue stats, VTT override."""
|
||||
from datetime import datetime
|
||||
|
||||
from ...services.cloud_run_dispatch import dispatch as _cr_dispatch
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Query,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...core.database import get_database
|
||||
from ...core.dependencies import require_roles
|
||||
from ...core.logging import get_logger
|
||||
from ...core.redis import get_redis
|
||||
from ...models.audit_log import AuditAction
|
||||
from ...models.job import JobStatus, RequestedOutputs
|
||||
from ...models.user import User, UserRole
|
||||
from ...schemas.job import JobResponse
|
||||
from ...services.audit_logger import audit_logger
|
||||
from ...tasks.ingest_and_ai import ingest_and_ai_task
|
||||
from ...tasks.translate_and_synthesize import translate_and_synthesize_task
|
||||
from ...services.cloud_run_dispatch import dispatch as _cr_dispatch
|
||||
from ...services.gcs import upload_vtt_to_gcs
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/admin/production", tags=["admin-production"])
|
||||
|
|
@ -147,7 +156,7 @@ async def bulk_retry(
|
|||
elif step in ("translation", "tts"):
|
||||
await _cr_dispatch("translate", job_id)
|
||||
elif step == "render":
|
||||
lang = job.get("last_render_language", "en")
|
||||
lang = job_doc.get("last_render_language", "en")
|
||||
await _cr_dispatch("rerender", job_id, language=lang)
|
||||
|
||||
retried.append(job_id)
|
||||
|
|
@ -169,3 +178,118 @@ async def bulk_retry(
|
|||
logger.warning(f"Failed to write bulk-retry audit log: {e}")
|
||||
|
||||
return BulkRetryResponse(retried=retried, skipped=skipped, errors=errors)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PR-7: Queue depth stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CELERY_QUEUES = ["default", "ingest", "tts", "render", "ffmpeg", "whisper", "notify", "embed"]
|
||||
|
||||
|
||||
class QueueStats(BaseModel):
|
||||
queues: dict[str, int] # queue_name → pending task count
|
||||
total_pending: int
|
||||
|
||||
|
||||
@router.get("/queue-stats", response_model=QueueStats)
|
||||
async def get_queue_stats(
|
||||
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
"""Return pending task counts per Celery queue (via Redis LLEN)."""
|
||||
counts: dict[str, int] = {}
|
||||
for q in _CELERY_QUEUES:
|
||||
try:
|
||||
n = await redis.llen(q)
|
||||
counts[q] = n
|
||||
except Exception:
|
||||
counts[q] = 0
|
||||
return QueueStats(queues=counts, total_pending=sum(counts.values()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PR-8: Upload final VTT override — bypass AI, jump to PENDING_QC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BYPASSABLE_STATUSES = {
|
||||
JobStatus.CREATED.value,
|
||||
JobStatus.INGESTING.value,
|
||||
JobStatus.AI_PROCESSING.value,
|
||||
JobStatus.PROCESSING_FAILED.value,
|
||||
JobStatus.TTS_FAILED.value,
|
||||
JobStatus.RENDER_FAILED.value,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/upload-final-vtt")
|
||||
async def upload_final_vtt(
|
||||
job_id: str,
|
||||
language: str = Form(..., description="BCP-47 language code, e.g. 'en' or 'fr'"),
|
||||
vtt_file: UploadFile = File(..., description="WebVTT (.vtt) file"),
|
||||
vtt_type: str = Form("captions", description="'captions' or 'ad'"),
|
||||
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Upload a hand-crafted VTT to override AI output and advance job to PENDING_QC."""
|
||||
job_doc = await db.jobs.find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
if job_doc["status"] not in _BYPASSABLE_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Cannot override VTT when job is in status '{job_doc['status']}'. "
|
||||
f"Only allowed in: {sorted(_BYPASSABLE_STATUSES)}",
|
||||
)
|
||||
|
||||
if not vtt_file.filename or not vtt_file.filename.endswith(".vtt"):
|
||||
raise HTTPException(status_code=400, detail="File must be a .vtt file")
|
||||
|
||||
vtt_content = (await vtt_file.read()).decode("utf-8")
|
||||
if not vtt_content.strip().startswith("WEBVTT"):
|
||||
raise HTTPException(status_code=400, detail="File does not start with WEBVTT header")
|
||||
|
||||
if vtt_type not in ("captions", "ad"):
|
||||
raise HTTPException(status_code=400, detail="vtt_type must be 'captions' or 'ad'")
|
||||
|
||||
lang_key = language.replace("-", "_")
|
||||
field = "captions_vtt_gcs" if vtt_type == "captions" else "ad_vtt_gcs"
|
||||
gcs_path = f"{job_id}/{lang_key}/{vtt_type}.vtt"
|
||||
|
||||
gcs_uri = await upload_vtt_to_gcs(vtt_content, gcs_path)
|
||||
|
||||
now = datetime.utcnow()
|
||||
await db.jobs.update_one(
|
||||
{"_id": job_id},
|
||||
{
|
||||
"$set": {
|
||||
f"outputs.{lang_key}.{field}": gcs_uri,
|
||||
"status": JobStatus.PENDING_QC.value,
|
||||
"updated_at": now,
|
||||
},
|
||||
"$push": {
|
||||
"review.history": {
|
||||
"at": now,
|
||||
"status": "manual_vtt_upload",
|
||||
"by": str(current_user.id),
|
||||
"note": f"Manual {vtt_type} VTT upload for {language} by {current_user.email}",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await audit_logger.log(
|
||||
action=AuditAction.VTT_EDIT,
|
||||
user_id=str(current_user.id),
|
||||
user_email=current_user.email,
|
||||
user_role=current_user.role.value if current_user.role else None,
|
||||
resource_type="job",
|
||||
resource_id=job_id,
|
||||
description=f"Manual {vtt_type} VTT upload for {language} — job advanced to PENDING_QC",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write upload-final-vtt audit log: {e}")
|
||||
|
||||
return {"status": "ok", "gcs_uri": gcs_uri, "job_status": JobStatus.PENDING_QC.value}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# =============================================================================
|
||||
# optical-dev overrides — 2 CPU / ~8 GB RAM server
|
||||
#
|
||||
# Heavy pipeline workers (ingest, translate, render, rerender) run on
|
||||
# Cloud Run Jobs. Only lightweight services run here.
|
||||
# Cloud Run Jobs (va-worker) are NOT yet reachable from this server
|
||||
# (VPC Connector pending). Until then USE_CELERY_FALLBACK=true routes all
|
||||
# heavy tasks through local Celery workers constrained to WORKER_CONCURRENCY=2
|
||||
# so they fit in 2 CPU without OOM on large videos.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml \
|
||||
|
|
@ -45,43 +47,53 @@ services:
|
|||
cpus: '0.5'
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
# Cloud Run dispatch config
|
||||
CLOUD_RUN_WORKER_JOB: va-worker
|
||||
GCP_REGION: europe-west1
|
||||
USE_CELERY_FALLBACK: "false"
|
||||
# Fallback mode: bypass Cloud Run, dispatch heavy tasks to local workers
|
||||
USE_CELERY_FALLBACK: "true"
|
||||
WORKER_CONCURRENCY: "2"
|
||||
|
||||
# Lightweight worker: only notify + embed_glossary tasks
|
||||
# Heavy tasks (ingest/translate/render) go to Cloud Run Jobs
|
||||
# Full worker: handles ALL queues in fallback mode
|
||||
worker:
|
||||
deploy:
|
||||
replicas: 1
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '0.75'
|
||||
reservations:
|
||||
memory: 1G
|
||||
cpus: '0.25'
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
WORKER_CONCURRENCY: "2"
|
||||
command: >
|
||||
celery -A app.tasks worker
|
||||
--loglevel=info
|
||||
--queues=default,ingest,tts,render,ffmpeg,whisper,notify,embed
|
||||
--concurrency=2
|
||||
--hostname=full-worker@%h
|
||||
|
||||
# ── Pipeline workers — enabled in fallback mode ────────────────────────────
|
||||
|
||||
ffmpeg-worker:
|
||||
deploy:
|
||||
replicas: 1
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: '0.5'
|
||||
|
||||
tts-worker:
|
||||
deploy:
|
||||
replicas: 1
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.25'
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.1'
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
# Only consume lightweight queues; heavy queues handled by Cloud Run
|
||||
CELERY_QUEUES: "notify,embed"
|
||||
command: >
|
||||
celery -A app.tasks worker
|
||||
--loglevel=info
|
||||
--queues=notify,embed
|
||||
--concurrency=2
|
||||
--hostname=lite-worker@%h
|
||||
|
||||
# ── Disabled on optical-dev — run on Cloud Run Jobs instead ───────────────
|
||||
|
||||
ffmpeg-worker:
|
||||
deploy:
|
||||
replicas: 0
|
||||
|
||||
tts-worker:
|
||||
deploy:
|
||||
replicas: 0
|
||||
|
||||
whisper-worker:
|
||||
deploy:
|
||||
replicas: 0
|
||||
replicas: 1
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '0.5'
|
||||
|
|
|
|||
|
|
@ -1,48 +1,14 @@
|
|||
import type { JobStatus } from '../types/api';
|
||||
import { getJobStatusLabel } from '../utils/jobStatusMessages';
|
||||
import { getJobStatusLabel, getJobStatusColor } from '../utils/jobStatusMessages';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: JobStatus;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: StatusBadgeProps) {
|
||||
const getStatusStyles = (status: JobStatus) => {
|
||||
switch (status) {
|
||||
case 'created':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'ingesting':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'ai_processing':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'pending_qc':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'approved_english':
|
||||
case 'approved_source':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'rejected':
|
||||
case 'tts_failed':
|
||||
case 'render_failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'translating':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'tts_generating':
|
||||
return 'bg-indigo-100 text-indigo-800';
|
||||
case 'rendering_video':
|
||||
return 'bg-violet-100 text-violet-800';
|
||||
case 'pending_final_review':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = getJobStatusLabel;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusStyles(status)}`}>
|
||||
{getStatusLabel(status)}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getJobStatusColor(status)}`}>
|
||||
{getJobStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -98,17 +98,10 @@ export function TimelinePreview({
|
|||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
const handleContextMenuPauseOpen = (pp: PausePointData) => {
|
||||
const effectiveMs = pp.adjusted_ms ?? pp.original_ms;
|
||||
setEditorPosition({ x: contextMenu!.x, y: contextMenu!.y + 8 });
|
||||
setSelectedPausePoint(pp);
|
||||
onPausePointClick(pp);
|
||||
setContextMenu(null);
|
||||
if (timelineRef.current) {
|
||||
// seek video
|
||||
onPausePointClick(pp);
|
||||
}
|
||||
// Seek video
|
||||
const _ = effectiveMs; // used by consumer via onPausePointClick
|
||||
};
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
|
|
|
|||
|
|
@ -400,4 +400,26 @@ export function useBulkRetry() {
|
|||
queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProductionQueueStats() {
|
||||
return useQuery({
|
||||
queryKey: ['production-queue-stats'],
|
||||
queryFn: () => apiClient.getProductionQueueStats(),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadFinalVtt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
jobId, language, vttFile, vttType,
|
||||
}: { jobId: string; language: string; vttFile: File; vttType?: 'captions' | 'ad' }) =>
|
||||
apiClient.uploadFinalVtt(jobId, language, vttFile, vttType),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['failures'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1007,6 +1007,27 @@ class ApiClient {
|
|||
const r = await this.client.get(`/public/share/${token}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
// ── Production admin ────────────────────────────────────────────────────────
|
||||
|
||||
async getProductionQueueStats(): Promise<{ queues: Record<string, number>; total_pending: number }> {
|
||||
const r = await this.client.get('/admin/production/queue-stats');
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async uploadFinalVtt(
|
||||
jobId: string,
|
||||
language: string,
|
||||
vttFile: File,
|
||||
vttType: 'captions' | 'ad' = 'captions',
|
||||
): Promise<{ status: string; gcs_uri: string; job_status: string }> {
|
||||
const form = new FormData();
|
||||
form.append('language', language);
|
||||
form.append('vtt_type', vttType);
|
||||
form.append('vtt_file', vttFile);
|
||||
const r = await this.client.post(`/admin/production/jobs/${jobId}/upload-final-vtt`, form);
|
||||
return r.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../lib/auth';
|
||||
import { useJobs } from '../hooks/useJob';
|
||||
import { useJobs, useProductionQueueStats } from '../hooks/useJob';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import type { Job } from '../types/api';
|
||||
|
||||
export function Dashboard() {
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const { data: jobsData, isLoading, error } = useJobs({
|
||||
mine: user?.role === 'client'
|
||||
}, {
|
||||
enabled: isAuthenticated && !!user
|
||||
const { data: jobsData, isLoading, error } = useJobs({
|
||||
mine: user?.role === 'client'
|
||||
}, {
|
||||
enabled: isAuthenticated && !!user
|
||||
});
|
||||
const { data: queueStats } = useProductionQueueStats();
|
||||
|
||||
const jobs = jobsData?.jobs || [];
|
||||
|
||||
|
|
@ -138,6 +139,7 @@ export function Dashboard() {
|
|||
|
||||
case 'production':
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="bg-gradient-to-br from-blue-500 via-blue-600 to-indigo-700 rounded-2xl p-8 text-white">
|
||||
<div className="flex items-center mb-4">
|
||||
|
|
@ -185,6 +187,29 @@ export function Dashboard() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue depth panel — refreshes every 10s */}
|
||||
{queueStats && (
|
||||
<div className="mt-6 bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Celery Queue Depth</h3>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${queueStats.total_pending > 0 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{queueStats.total_pending} pending
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Object.entries(queueStats.queues)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([queue, count]) => (
|
||||
<div key={queue} className="flex flex-col items-center bg-gray-50 rounded-lg p-2">
|
||||
<span className={`text-lg font-bold ${count > 0 ? 'text-blue-600' : 'text-gray-400'}`}>{count}</span>
|
||||
<span className="text-xs text-gray-500 mt-0.5">{queue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
case 'reviewer':
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useFailures, useBulkRetry } from '../../hooks/useJob';
|
||||
import { useFailures, useBulkRetry, useUploadFinalVtt } from '../../hooks/useJob';
|
||||
import { StatusBadge } from '../../components/StatusBadge';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import { useAuthStore } from '../../lib/auth';
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
ingestion: 'Ingestion',
|
||||
|
|
@ -16,16 +17,23 @@ export function FailuresList() {
|
|||
const [stepFilter, setStepFilter] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [strategy, setStrategy] = useState<'auto' | 'from_scratch'>('auto');
|
||||
const [uploadTarget, setUploadTarget] = useState<{ jobId: string; title: string } | null>(null);
|
||||
const [uploadLanguage, setUploadLanguage] = useState('en');
|
||||
const [uploadType, setUploadType] = useState<'captions' | 'ad'>('captions');
|
||||
const uploadFileRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToastContext();
|
||||
const { user } = useAuthStore();
|
||||
const canUploadVtt = user?.role === 'production' || user?.role === 'admin';
|
||||
|
||||
const { data: jobs = [], isLoading, error, refetch } = useFailures(
|
||||
stepFilter ? { step: stepFilter } : undefined
|
||||
);
|
||||
const bulkRetryMutation = useBulkRetry();
|
||||
const uploadVttMutation = useUploadFinalVtt();
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) { next.delete(id); } else { next.add(id); }
|
||||
setSelected(next);
|
||||
};
|
||||
const selectAll = () => setSelected(new Set(jobs.map(j => j.id)));
|
||||
|
|
@ -46,6 +54,23 @@ export function FailuresList() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUploadVtt = async () => {
|
||||
if (!uploadTarget || !uploadFileRef.current?.files?.[0]) return;
|
||||
const file = uploadFileRef.current.files[0];
|
||||
try {
|
||||
await uploadVttMutation.mutateAsync({
|
||||
jobId: uploadTarget.jobId,
|
||||
language: uploadLanguage,
|
||||
vttFile: file,
|
||||
vttType: uploadType,
|
||||
});
|
||||
toast.toastOnly.success(`VTT uploaded — job advanced to Pending QC`);
|
||||
setUploadTarget(null);
|
||||
} catch {
|
||||
toast.toastOnly.error('VTT upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Group by failure type for accordion
|
||||
const byType = jobs.reduce<Record<string, typeof jobs>>((acc, job) => {
|
||||
const key = job.failure?.type || job.status;
|
||||
|
|
@ -181,13 +206,22 @@ export function FailuresList() {
|
|||
<td className="px-3 py-2 text-gray-400 whitespace-nowrap text-xs">
|
||||
{new Date(job.updated_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 flex items-center gap-2">
|
||||
<Link
|
||||
to={`/jobs/${job.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-800 text-xs"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
{canUploadVtt && (
|
||||
<button
|
||||
onClick={() => setUploadTarget({ jobId: job.id, title: job.title })}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-800"
|
||||
title="Upload final VTT to bypass AI"
|
||||
>
|
||||
Upload VTT
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -199,6 +233,60 @@ export function FailuresList() {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Upload final VTT modal */}
|
||||
{uploadTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">Upload Final VTT</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Override AI output for <span className="font-medium">{uploadTarget.title}</span> and advance to Pending QC.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Language (BCP-47)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadLanguage}
|
||||
onChange={e => setUploadLanguage(e.target.value)}
|
||||
placeholder="en"
|
||||
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">VTT type</label>
|
||||
<select
|
||||
value={uploadType}
|
||||
onChange={e => setUploadType(e.target.value as 'captions' | 'ad')}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="captions">Closed Captions</option>
|
||||
<option value="ad">Audio Description</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">VTT file</label>
|
||||
<input ref={uploadFileRef} type="file" accept=".vtt" className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-5">
|
||||
<button
|
||||
onClick={() => setUploadTarget(null)}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUploadVtt}
|
||||
disabled={uploadVttMutation.isPending}
|
||||
className="px-4 py-2 bg-emerald-600 text-white text-sm rounded hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{uploadVttMutation.isPending ? 'Uploading...' : 'Upload & Advance'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,4 +163,28 @@ export const JOB_STATUS_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
export const getJobStatusLabel = (status: string): string =>
|
||||
JOB_STATUS_LABELS[status] ?? status.replace(/_/g, ' ');
|
||||
JOB_STATUS_LABELS[status] ?? status.replace(/_/g, ' ');
|
||||
|
||||
/** Tailwind classes for job status badges (bg + text). */
|
||||
export function getJobStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'created': return 'bg-gray-100 text-gray-800';
|
||||
case 'ingesting': return 'bg-blue-100 text-blue-800';
|
||||
case 'ai_processing': return 'bg-purple-100 text-purple-800';
|
||||
case 'pending_qc': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'approved_english':
|
||||
case 'approved_source':
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'rejected':
|
||||
case 'tts_failed':
|
||||
case 'render_failed':
|
||||
case 'processing_failed': return 'bg-red-100 text-red-800';
|
||||
case 'qc_feedback': return 'bg-orange-100 text-orange-800';
|
||||
case 'translating': return 'bg-blue-100 text-blue-800';
|
||||
case 'tts_generating': return 'bg-indigo-100 text-indigo-800';
|
||||
case 'rendering_video': return 'bg-violet-100 text-violet-800';
|
||||
case 'rendering_qc': return 'bg-violet-100 text-violet-800';
|
||||
case 'pending_final_review':return 'bg-orange-100 text-orange-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue