import { useEffect, useRef, useState, useCallback } from 'react'; import { useNavigate, useParams, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { usePageTitle } from '../../hooks/usePageTitle'; import { runs, runFiles } from '../../api'; import type { Scenario, ScenarioRunDetail, ScenarioRunStep, ScenarioRunLog, ScenarioRunStatus, RunStepStatus, LogLevel, FileMetadata, } from '../../api'; import { scenarios } from '../../api'; import { AutoRefreshIndicator, Badge, Breadcrumbs, Card, CodeBlock, DescriptionList, Pagination, Search, Table, Timestamp, UuidBadge, type TableColumn, } from '../../ui'; import type { BadgeVariant } from '../../ui'; import { ChevronDown, ChevronRight, ClipboardList, Activity } from 'lucide-react'; import styles from '../Page.module.css'; const RUN_STATUS_VARIANT: Record = { pending: 'neutral', in_progress: 'info', pass: 'success', fail: 'error', }; const RUN_STATUS_LABEL: Record = { pending: 'Pending', in_progress: 'Running', pass: 'Pass', fail: 'Fail', }; const STEP_STATUS_VARIANT: Record = { waiting: 'neutral', pending: 'neutral', in_progress: 'info', pass: 'success', fail: 'error', cancelled: 'warning', }; const LOG_LEVEL_VARIANT: Record = { log: 'neutral', warn: 'warning', error: 'error', }; const FINAL: ScenarioRunStatus[] = ['pass', 'fail']; export function RunDetailPage() { const { t } = useTranslation(); const { id, runId } = useParams<{ id: string; runId: string }>(); const navigate = useNavigate(); const [scenario, setScenario] = useState(null); const [run, setRun] = useState(null); const [loading, setLoading] = useState(true); usePageTitle(scenario ? `${t('runs.title')} — ${scenario.name}` : t('runs.title')); const [error, setError] = useState(null); const [polling, setPolling] = useState(false); const [pulseKey, setPulseKey] = useState(0); const [logPage, setLogPage] = useState(1); const [logSearch, setLogSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [expandAll, setExpandAll] = useState(false); const [expandedStepIds, setExpandedStepIds] = useState>(new Set()); const [artifacts, setArtifacts] = useState([]); const [artifactsTotal, setArtifactsTotal] = useState(0); const [artifactsLoading, setArtifactsLoading] = useState(false); const logSearchRef = useRef(''); const LOG_PAGE_SIZE = 25; const pollRef = useRef | null>(null); const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; setPolling(false); } }; const manualRefresh = () => { if (!id || !runId) return; runs .get(id, runId, logSearchRef.current) .then((r) => { setRun(r); setError(null); setPulseKey((k) => k + 1); }) .catch((err: Error) => setError(err.message)); }; const loadArtifacts = useCallback(async () => { if (!id || !runId) return; setArtifactsLoading(true); try { const result = await runFiles.list(id, runId); setArtifacts(result.items); setArtifactsTotal(result.total); } catch { // Silently fail - artifacts are not critical setArtifacts([]); setArtifactsTotal(0); } finally { setArtifactsLoading(false); } }, [id, runId]); useEffect(() => { const t = setTimeout(() => { setDebouncedSearch(logSearch); logSearchRef.current = logSearch; setLogPage(1); }, 300); return () => clearTimeout(t); }, [logSearch]); useEffect(() => { if (!id || !runId || polling) return; runs .get(id, runId, debouncedSearch) .then(setRun) .catch(() => undefined); }, [id, runId, polling, debouncedSearch]); useEffect(() => { if (!id || !runId) return; let cancelled = false; Promise.all([scenarios.get(id), runs.get(id, runId)]) .then(([sc, r]) => { if (cancelled) return; setScenario(sc); setRun(r); loadArtifacts(); if (!FINAL.includes(r.status)) { setPolling(true); pollRef.current = setInterval(async () => { try { const updated = await runs.get(id, runId, logSearchRef.current); if (cancelled) return; setRun(updated); setPulseKey((k) => k + 1); if (FINAL.includes(updated.status)) { stopPolling(); await loadArtifacts(); } } catch { stopPolling(); } }, 1000); } }) .catch((err: Error) => { if (!cancelled) setError(err.message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; stopPolling(); }; }, [id, runId, loadArtifacts]); const stepColumns: TableColumn[] = [ { key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 }, { key: 'title', header: t('runs.step_title'), width: 220, render: (s) => s.scenarioStep?.title ?? , }, { key: 'status', header: t('runs.step_status'), width: 110, render: (s) => ( {s.status.replace('_', ' ')} ), }, { key: 'description', header: (
{t('runs.step_description')} {run?.stepRuns.some((s) => s.output) && ( )}
), render: (s) => { const isExpanded = expandAll || expandedStepIds.has(s.id); return (
{s.output && ( )} {s.description ? ( s.description ) : s.output ? ( {s.output.length} bytes ) : ( )}
{isExpanded && s.output && (
{ try { return JSON.stringify(JSON.parse(s.output), null, 2); } catch { return s.output; } })()} language="json" />
)}
); }, }, ]; const logColumns: TableColumn[] = [ { key: 'level', header: t('runs.log_level'), width: 70, render: (l) => {l.level}, }, { key: 'message', header: t('runs.log_message'), render: (l) => {l.message}, }, { key: 'time', header: t('runs.log_time'), width: 160, render: (l) => , }, ]; return (
, onClick: () => navigate('/scenarios'), }, { label: scenario?.name ?? `${id}`, onClick: () => navigate(`/scenarios/${id}`), }, { label: t('runs.title'), icon: , onClick: () => navigate(`/scenarios/${id}/runs`), }, { label: `${runId}` }, ]} />
{loading &&

{t('runs.loading')}

} {run && ( <> { const items = [ { term: t('runs.field_id'), detail: }, { term: t('runs.field_status'), detail: ( {RUN_STATUS_LABEL[run.status]} ), }, { term: t('runs.field_created'), detail: }, { term: t('runs.field_updated'), detail: }, ]; if (run.environment) { items.push({ term: 'Environment', detail: ( {run.environment.name} ), }); } if (run.session) { items.push({ term: 'Session', detail: ( {run.session.sessionName} ), }); } return items; })()} /> {run.stepRuns.length > 0 && (

{t('runs.steps_heading')}

s.id} loading={false} emptyMessage="" /> )} {run.logs.length > 0 && (

{t('runs.logs_heading')}

l.id} loading={false} emptyMessage="" /> {run.logs.length > LOG_PAGE_SIZE && ( )} )} {(artifactsTotal > 0 || artifactsLoading) && (

{t('files.artifactsTitle')}

loading={artifactsLoading} data={artifacts} rowKey={(f) => f.id} columns={ [ { key: 'name', header: t('files.name'), render: (f) => f.name }, { key: 'mimeType', header: t('files.mimeType'), render: (f) => f.mimeType, }, { key: 'size', header: t('files.size'), render: (f) => `${(f.size / 1024).toFixed(1)} KB`, }, { key: 'expiresAt', header: t('files.expiresAt'), render: (f) => (f.expiresAt ? : '—'), }, { key: 'createdAt', header: t('files.createdAt'), render: (f) => , }, { key: 'download', header: '', render: (f) => ( {t('files.download')} ), }, ] satisfies TableColumn[] } emptyMessage={t('files.empty')} />
)} )} ); }