- add scenario upload and download UI with typed file API helpers - show run artifacts on run detail pages after polling completes - handle multipart uploads and absolute file paths for downloads
501 lines
15 KiB
TypeScript
501 lines
15 KiB
TypeScript
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<ScenarioRunStatus, BadgeVariant> = {
|
||
pending: 'neutral',
|
||
in_progress: 'info',
|
||
pass: 'success',
|
||
fail: 'error',
|
||
};
|
||
|
||
const RUN_STATUS_LABEL: Record<ScenarioRunStatus, string> = {
|
||
pending: 'Pending',
|
||
in_progress: 'Running',
|
||
pass: 'Pass',
|
||
fail: 'Fail',
|
||
};
|
||
|
||
const STEP_STATUS_VARIANT: Record<RunStepStatus, BadgeVariant> = {
|
||
waiting: 'neutral',
|
||
pending: 'neutral',
|
||
in_progress: 'info',
|
||
pass: 'success',
|
||
fail: 'error',
|
||
cancelled: 'warning',
|
||
};
|
||
|
||
const LOG_LEVEL_VARIANT: Record<LogLevel, BadgeVariant> = {
|
||
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<Scenario | null>(null);
|
||
const [run, setRun] = useState<ScenarioRunDetail | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
usePageTitle(scenario ? `${t('runs.title')} — ${scenario.name}` : t('runs.title'));
|
||
const [error, setError] = useState<string | null>(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<Set<string>>(new Set());
|
||
const [artifacts, setArtifacts] = useState<FileMetadata[]>([]);
|
||
const [artifactsTotal, setArtifactsTotal] = useState(0);
|
||
const [artifactsLoading, setArtifactsLoading] = useState(false);
|
||
const logSearchRef = useRef('');
|
||
const LOG_PAGE_SIZE = 25;
|
||
const pollRef = useRef<ReturnType<typeof setInterval> | 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<ScenarioRunStep>[] = [
|
||
{ 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 ?? <span className={styles.muted}>–</span>,
|
||
},
|
||
{
|
||
key: 'status',
|
||
header: t('runs.step_status'),
|
||
width: 110,
|
||
render: (s) => (
|
||
<Badge variant={STEP_STATUS_VARIANT[s.status]}>{s.status.replace('_', ' ')}</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: 'description',
|
||
header: (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '8px',
|
||
}}
|
||
>
|
||
<span>{t('runs.step_description')}</span>
|
||
{run?.stepRuns.some((s) => s.output) && (
|
||
<button
|
||
onClick={() => {
|
||
if (expandAll) {
|
||
setExpandAll(false);
|
||
setExpandedStepIds(new Set());
|
||
} else {
|
||
setExpandAll(true);
|
||
setExpandedStepIds(
|
||
new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? []),
|
||
);
|
||
}
|
||
}}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
padding: '0 4px',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
color: 'inherit',
|
||
}}
|
||
title={expandAll ? 'Collapse all' : 'Expand all'}
|
||
>
|
||
{expandAll ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
),
|
||
render: (s) => {
|
||
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
||
return (
|
||
<div>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: '8px',
|
||
marginBottom: isExpanded && s.output ? '8px' : 0,
|
||
}}
|
||
>
|
||
{s.output && (
|
||
<button
|
||
onClick={() => {
|
||
const newSet = new Set(expandedStepIds);
|
||
if (newSet.has(s.id)) {
|
||
newSet.delete(s.id);
|
||
} else {
|
||
newSet.add(s.id);
|
||
}
|
||
setExpandedStepIds(newSet);
|
||
}}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
padding: 0,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
flexShrink: 0,
|
||
marginTop: '2px',
|
||
}}
|
||
>
|
||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||
</button>
|
||
)}
|
||
<code style={{ wordBreak: 'break-word' }}>
|
||
{s.description ? (
|
||
s.description
|
||
) : s.output ? (
|
||
<span title={`${s.output.length} bytes`}>{s.output.length} bytes</span>
|
||
) : (
|
||
<span className={styles.muted}>–</span>
|
||
)}
|
||
</code>
|
||
</div>
|
||
{isExpanded && s.output && (
|
||
<div style={{ marginLeft: '24px' }}>
|
||
<CodeBlock
|
||
code={(() => {
|
||
try {
|
||
return JSON.stringify(JSON.parse(s.output), null, 2);
|
||
} catch {
|
||
return s.output;
|
||
}
|
||
})()}
|
||
language="json"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
const logColumns: TableColumn<ScenarioRunLog>[] = [
|
||
{
|
||
key: 'level',
|
||
header: t('runs.log_level'),
|
||
width: 70,
|
||
render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>,
|
||
},
|
||
{
|
||
key: 'message',
|
||
header: t('runs.log_message'),
|
||
render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code>,
|
||
},
|
||
{
|
||
key: 'time',
|
||
header: t('runs.log_time'),
|
||
width: 160,
|
||
render: (l) => <Timestamp value={l.createdAt} />,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<div className={styles.pageToolbar}>
|
||
<Breadcrumbs
|
||
items={[
|
||
{
|
||
label: t('scenarios.title'),
|
||
icon: <ClipboardList size={14} />,
|
||
onClick: () => navigate('/scenarios'),
|
||
},
|
||
{
|
||
label: scenario?.name ?? `${id}`,
|
||
onClick: () => navigate(`/scenarios/${id}`),
|
||
},
|
||
{
|
||
label: t('runs.title'),
|
||
icon: <Activity size={14} />,
|
||
onClick: () => navigate(`/scenarios/${id}/runs`),
|
||
},
|
||
{ label: `${runId}` },
|
||
]}
|
||
/>
|
||
<AutoRefreshIndicator
|
||
active={polling}
|
||
pulseKey={pulseKey}
|
||
error={!!error}
|
||
onClick={manualRefresh}
|
||
/>
|
||
</div>
|
||
|
||
{loading && <p className={styles.muted}>{t('runs.loading')}</p>}
|
||
|
||
{run && (
|
||
<>
|
||
<Card>
|
||
<DescriptionList
|
||
layout="grid"
|
||
items={(() => {
|
||
const items = [
|
||
{ term: t('runs.field_id'), detail: <UuidBadge id={run.id} /> },
|
||
{
|
||
term: t('runs.field_status'),
|
||
detail: (
|
||
<Badge variant={RUN_STATUS_VARIANT[run.status]}>
|
||
{RUN_STATUS_LABEL[run.status]}
|
||
</Badge>
|
||
),
|
||
},
|
||
{ term: t('runs.field_created'), detail: <Timestamp value={run.createdAt} /> },
|
||
{ term: t('runs.field_updated'), detail: <Timestamp value={run.updatedAt} /> },
|
||
];
|
||
if (run.environment) {
|
||
items.push({
|
||
term: 'Environment',
|
||
detail: (
|
||
<Link
|
||
to={`/environments/${run.environment.id}`}
|
||
style={{ color: 'var(--color-link)' }}
|
||
>
|
||
{run.environment.name}
|
||
</Link>
|
||
),
|
||
});
|
||
}
|
||
if (run.session) {
|
||
items.push({
|
||
term: 'Session',
|
||
detail: (
|
||
<Link
|
||
to={`/sessions/${run.session.id}`}
|
||
style={{ color: 'var(--color-link)' }}
|
||
>
|
||
{run.session.sessionName}
|
||
</Link>
|
||
),
|
||
});
|
||
}
|
||
return items;
|
||
})()}
|
||
/>
|
||
</Card>
|
||
|
||
{run.stepRuns.length > 0 && (
|
||
<div className={styles.stepsSection}>
|
||
<h2 className={styles.sectionHeading}>{t('runs.steps_heading')}</h2>
|
||
<Table
|
||
columns={stepColumns}
|
||
data={run.stepRuns}
|
||
rowKey={(s) => s.id}
|
||
loading={false}
|
||
emptyMessage=""
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{run.logs.length > 0 && (
|
||
<div className={styles.stepsSection}>
|
||
<div className={styles.sectionHeadingRow}>
|
||
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
|
||
<Search
|
||
value={logSearch}
|
||
onChange={setLogSearch}
|
||
placeholder={t('runs.logs_search_placeholder')}
|
||
/>
|
||
</div>
|
||
<Table
|
||
columns={logColumns}
|
||
data={run.logs.slice((logPage - 1) * LOG_PAGE_SIZE, logPage * LOG_PAGE_SIZE)}
|
||
rowKey={(l) => l.id}
|
||
loading={false}
|
||
emptyMessage=""
|
||
/>
|
||
{run.logs.length > LOG_PAGE_SIZE && (
|
||
<Pagination
|
||
page={logPage}
|
||
pageSize={LOG_PAGE_SIZE}
|
||
total={run.logs.length}
|
||
onPageChange={setLogPage}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(artifactsTotal > 0 || artifactsLoading) && (
|
||
<div className={styles.stepsSection}>
|
||
<h2 className={styles.sectionHeading}>{t('files.artifactsTitle')}</h2>
|
||
<Table<FileMetadata>
|
||
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 ? <Timestamp value={f.expiresAt} /> : '—'),
|
||
},
|
||
{
|
||
key: 'createdAt',
|
||
header: t('files.createdAt'),
|
||
render: (f) => <Timestamp value={f.createdAt} />,
|
||
},
|
||
{
|
||
key: 'download',
|
||
header: '',
|
||
render: (f) => (
|
||
<a href={runFiles.contentUrl(id!, runId!, f.id)} download={f.name}>
|
||
{t('files.download')}
|
||
</a>
|
||
),
|
||
},
|
||
] satisfies TableColumn<FileMetadata>[]
|
||
}
|
||
emptyMessage={t('files.empty')}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|