fix(ui): connection dot in navbar, profile page, render error visibility + audit log

- Navbar: add WebSocket connection dot (green/yellow/red/gray) from GlobalWebSocketContext
- Profile page: /profile route shows email, full_name, role, auth_provider, languages
- JobResponse: expose failure and error fields (were stored in MongoDB but not returned)
  so frontend now shows actual render error message instead of generic fallback
- render_accessible_video: write JOB_TASK_FAILED audit log entry on render failure
  with language, error detail, step=render
- rerender_accessible_video: same audit log on re-render failure, step=rerender

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-01 16:19:12 +01:00
parent 2f4925353a
commit df7fec701d
7 changed files with 158 additions and 3 deletions

View file

@ -807,6 +807,9 @@ async def get_job(
requested_outputs=RequestedOutputs(**job_doc["requested_outputs"]),
review=job_doc.get("review", {"notes": "", "history": []}),
outputs=job_doc.get("outputs"),
accessible_video_progress=job_doc.get("accessible_video_progress"),
failure=job_doc.get("failure"),
error=job_doc.get("error"),
created_at=job_doc["created_at"].isoformat(),
updated_at=job_doc["updated_at"].isoformat(),
cost_tracker_project_id=job_doc.get("cost_tracker_project_id"),

View file

@ -4,6 +4,7 @@ from pydantic import BaseModel, field_validator
from ..models.job import (
AccessibleVideoProgressItem,
JobFailure,
JobStatus,
LangOutput,
RequestedOutputs,
@ -23,6 +24,8 @@ class JobResponse(BaseModel):
review: Review
outputs: dict[str, LangOutput] | None = None
accessible_video_progress: dict[str, AccessibleVideoProgressItem] | None = None
failure: JobFailure | None = None
error: dict[str, Any] | None = None
created_at: str | None = None
updated_at: str | None = None
created_by_name: str | None = None # User's full_name who created the job

View file

@ -11,6 +11,7 @@ from motor.motor_asyncio import AsyncIOMotorClient
from ..core.config import settings
from ..core.logging import get_logger
from ..lib.vtt import VTTParser
from ..models.audit_log import AuditAction, AuditLogCreate, AuditLogSeverity
from ..models.job import (
AccessibleVideoEditState,
JobStatus,
@ -319,7 +320,10 @@ async def _async_render_accessible_video(job_id: str, language: str):
logger.info(f"Accessible video render complete for job {job_id}/{language}")
except Exception as e:
logger.error(f"Accessible video render failed for job {job_id}/{language}: {e}")
import traceback
error_detail = str(e)
logger.error(f"Accessible video render failed for job {job_id}/{language}: {error_detail}")
logger.error(traceback.format_exc())
# Update progress to failed
await db.jobs.update_one(
@ -328,7 +332,7 @@ async def _async_render_accessible_video(job_id: str, language: str):
"$set": {
f"accessible_video_progress.{language}": {
"status": "failed",
"error_message": str(e),
"error_message": error_detail,
"completed_at": datetime.utcnow()
},
"updated_at": datetime.utcnow()
@ -336,6 +340,23 @@ async def _async_render_accessible_video(job_id: str, language: str):
}
)
# Write audit log entry
try:
job_doc_for_audit = await db.jobs.find_one({"_id": job_id}, {"title": 1})
job_title_for_audit = (job_doc_for_audit or {}).get("title", job_id)
audit_entry = AuditLogCreate(
action=AuditAction.JOB_TASK_FAILED,
severity=AuditLogSeverity.ERROR,
description=f"Render failed for job '{job_title_for_audit}' [{language.upper()}]: {error_detail[:300]}",
resource_type="job",
resource_id=job_id,
resource_name=job_title_for_audit,
details={"language": language, "error": error_detail[:1000], "step": "render"},
)
await db.audit_logs.insert_one(audit_entry.model_dump())
except Exception as audit_err:
logger.warning(f"Failed to write audit log for render failure: {audit_err}")
# Check if all videos are now finished (completed or failed) to update job status
# This ensures the job transitions to RENDER_FAILED if all languages have finished
await _check_accessible_video_completion(job_id, db)

View file

@ -12,6 +12,7 @@ from pydub import AudioSegment
from ..core.config import settings
from ..core.logging import get_logger
from ..lib.vtt import VTTParser
from ..models.audit_log import AuditAction, AuditLogCreate, AuditLogSeverity
from ..models.job import (
AccessibleVideoEditState,
JobStatus,
@ -106,12 +107,28 @@ async def _mark_rerender_failed(job_id: str, language: str, error_message: str):
)
job_doc = await db.jobs.find_one({"_id": job_id})
job_title = (job_doc or {}).get("title", job_id)
broadcast_status_update(
job_id,
JobStatus.PENDING_QC.value,
job_title=job_doc.get("title") if job_doc else None,
job_title=job_title,
message=f"Re-render failed: {error_message[:100]}"
)
# Write audit log entry
try:
audit_entry = AuditLogCreate(
action=AuditAction.JOB_TASK_FAILED,
severity=AuditLogSeverity.ERROR,
description=f"Re-render failed for job '{job_title}' [{language.upper()}]: {error_message[:300]}",
resource_type="job",
resource_id=job_id,
resource_name=job_title,
details={"language": language, "error": error_message[:1000], "step": "rerender"},
)
await db.audit_logs.insert_one(audit_entry.model_dump())
except Exception as audit_err:
logger.warning(f"Failed to write audit log for re-render failure: {audit_err}")
finally:
client.close()

View file

@ -30,6 +30,7 @@ import { LinguistQueue } from './routes/jobs/LinguistQueue';
import { ReviewerQueue } from './routes/jobs/ReviewerQueue';
import { Downloads } from './routes/Downloads';
import { Help } from './routes/Help';
import { Profile } from './routes/Profile';
import { ShareView } from './routes/ShareView';
import { AcceptInvite } from './routes/AcceptInvite';
import { NoAccess } from './routes/NoAccess';
@ -240,6 +241,11 @@ function AppContent() {
<Help />
</AuthenticatedRoute>
} />
<Route path="/profile" element={
<AuthenticatedRoute>
<Profile />
</AuthenticatedRoute>
} />
</Routes>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>

View file

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuthStore } from '../../lib/auth';
import { NotificationMenu } from '../NotificationMenu';
import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext';
interface NavbarProps {
onMobileMenuClick?: () => void;
@ -9,6 +10,7 @@ interface NavbarProps {
export function Navbar({ onMobileMenuClick }: NavbarProps) {
const { user, logout } = useAuthStore();
const { connectionStatus } = useGlobalWebSocket();
const [showUserMenu, setShowUserMenu] = useState(false);
const handleLogout = async () => {
@ -36,6 +38,22 @@ export function Navbar({ onMobileMenuClick }: NavbarProps) {
{/* Right side actions */}
<div className="flex items-center space-x-4">
{/* WebSocket connection status dot */}
<span
title={
connectionStatus === 'connected' ? 'Real-time updates active' :
connectionStatus === 'connecting' ? 'Connecting…' :
connectionStatus === 'error' ? 'Connection error' :
'Disconnected'
}
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
connectionStatus === 'connected' ? 'bg-green-400' :
connectionStatus === 'connecting' ? 'bg-yellow-400 animate-pulse' :
connectionStatus === 'error' ? 'bg-red-400' :
'bg-gray-300'
}`}
/>
{/* Notifications */}
<NotificationMenu />

View file

@ -0,0 +1,87 @@
import { useAuthStore } from '../lib/auth';
const ROLE_LABELS: Record<string, string> = {
admin: 'Admin',
project_manager: 'Project Manager',
production: 'Production',
reviewer: 'Reviewer',
linguist: 'Linguist',
client: 'Client',
};
export function Profile() {
const { user } = useAuthStore();
if (!user) return null;
const initials = user.full_name
? user.full_name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2)
: user.email.charAt(0).toUpperCase();
return (
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
<h1 className="text-2xl font-semibold text-gray-900">Profile</h1>
{/* Avatar + name */}
<div className="bg-white rounded-xl border border-gray-200 p-6 flex items-center gap-5">
<div className="w-16 h-16 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white text-xl font-semibold">{initials}</span>
</div>
<div>
<p className="text-lg font-semibold text-gray-900">{user.full_name || user.email}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<span className="inline-block mt-1 px-2.5 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-700 rounded-full capitalize">
{ROLE_LABELS[user.role] ?? user.role}
</span>
</div>
</div>
{/* Account details */}
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
<div className="px-6 py-4">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-3">Account details</p>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{user.email}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Full name</dt>
<dd className="text-sm text-gray-900">{user.full_name || '—'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Role</dt>
<dd className="text-sm text-gray-900">{ROLE_LABELS[user.role] ?? user.role}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Sign-in method</dt>
<dd className="text-sm text-gray-900 capitalize">
{user.auth_provider === 'microsoft' ? 'Microsoft (SSO)' : 'Email / password'}
</dd>
</div>
{user.languages && user.languages.length > 0 && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Languages</dt>
<dd className="text-sm text-gray-900">{user.languages.join(', ')}</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Account status</dt>
<dd className="text-sm">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</dd>
</div>
</dl>
</div>
</div>
{user.auth_provider !== 'microsoft' && (
<p className="text-xs text-gray-400 text-center">
To change your password, contact your administrator.
</p>
)}
</div>
);
}