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:
parent
2f4925353a
commit
df7fec701d
7 changed files with 158 additions and 3 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
87
frontend/src/routes/Profile.tsx
Normal file
87
frontend/src/routes/Profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue