From df7fec701d274b0f3df44362667bfdbfbb8e3658 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 1 May 2026 16:19:12 +0100 Subject: [PATCH] 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 --- backend/app/api/v1/routes_jobs.py | 3 + backend/app/schemas/job.py | 3 + backend/app/tasks/render_accessible_video.py | 25 +++++- .../app/tasks/rerender_accessible_video.py | 19 +++- frontend/src/App.tsx | 6 ++ frontend/src/components/Layout/Navbar.tsx | 18 ++++ frontend/src/routes/Profile.tsx | 87 +++++++++++++++++++ 7 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/Profile.tsx diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 4b6102e..96d3f54 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -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"), diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index 8be9776..7c13fc2 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -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 diff --git a/backend/app/tasks/render_accessible_video.py b/backend/app/tasks/render_accessible_video.py index 6289830..5725f12 100644 --- a/backend/app/tasks/render_accessible_video.py +++ b/backend/app/tasks/render_accessible_video.py @@ -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) diff --git a/backend/app/tasks/rerender_accessible_video.py b/backend/app/tasks/rerender_accessible_video.py index e19d53e..5a35d11 100644 --- a/backend/app/tasks/rerender_accessible_video.py +++ b/backend/app/tasks/rerender_accessible_video.py @@ -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() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 642abe0..b28bb2b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + } /> diff --git a/frontend/src/components/Layout/Navbar.tsx b/frontend/src/components/Layout/Navbar.tsx index 4dcea52..7d1da8f 100644 --- a/frontend/src/components/Layout/Navbar.tsx +++ b/frontend/src/components/Layout/Navbar.tsx @@ -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 */}
+ {/* WebSocket connection status dot */} + + {/* Notifications */} diff --git a/frontend/src/routes/Profile.tsx b/frontend/src/routes/Profile.tsx new file mode 100644 index 0000000..a024c6b --- /dev/null +++ b/frontend/src/routes/Profile.tsx @@ -0,0 +1,87 @@ +import { useAuthStore } from '../lib/auth'; + +const ROLE_LABELS: Record = { + 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 ( +
+

Profile

+ + {/* Avatar + name */} +
+
+ {initials} +
+
+

{user.full_name || user.email}

+

{user.email}

+ + {ROLE_LABELS[user.role] ?? user.role} + +
+
+ + {/* Account details */} +
+
+

Account details

+
+
+
Email
+
{user.email}
+
+
+
Full name
+
{user.full_name || '—'}
+
+
+
Role
+
{ROLE_LABELS[user.role] ?? user.role}
+
+
+
Sign-in method
+
+ {user.auth_provider === 'microsoft' ? 'Microsoft (SSO)' : 'Email / password'} +
+
+ {user.languages && user.languages.length > 0 && ( +
+
Languages
+
{user.languages.join(', ')}
+
+ )} +
+
Account status
+
+ + {user.is_active ? 'Active' : 'Inactive'} + +
+
+
+
+
+ + {user.auth_provider !== 'microsoft' && ( +

+ To change your password, contact your administrator. +

+ )} +
+ ); +}