Files
liqa/client/src/pages/run/RunDetailPage.tsx
T
ars9 86f9ac5603 feat(files): add file management UI for scenarios and runs
- 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
2026-04-21 15:58:04 +03:00

501 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}