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
This commit is contained in:
2026-04-21 15:58:04 +03:00
parent 6a91ce30e3
commit 86f9ac5603
20 changed files with 1237 additions and 96 deletions
+74 -4
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from 'react';
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 } from '../../api';
import { runs, runFiles } from '../../api';
import type {
Scenario,
ScenarioRunDetail,
@@ -11,6 +11,7 @@ import type {
ScenarioRunStatus,
RunStepStatus,
LogLevel,
FileMetadata,
} from '../../api';
import { scenarios } from '../../api';
import {
@@ -79,6 +80,9 @@ export function RunDetailPage() {
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);
@@ -103,6 +107,22 @@ export function RunDetailPage() {
.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);
@@ -129,6 +149,7 @@ export function RunDetailPage() {
if (cancelled) return;
setScenario(sc);
setRun(r);
loadArtifacts();
if (!FINAL.includes(r.status)) {
setPolling(true);
pollRef.current = setInterval(async () => {
@@ -137,7 +158,10 @@ export function RunDetailPage() {
if (cancelled) return;
setRun(updated);
setPulseKey((k) => k + 1);
if (FINAL.includes(updated.status)) stopPolling();
if (FINAL.includes(updated.status)) {
stopPolling();
await loadArtifacts();
}
} catch {
stopPolling();
}
@@ -155,7 +179,7 @@ export function RunDetailPage() {
cancelled = true;
stopPolling();
};
}, [id, runId]);
}, [id, runId, loadArtifacts]);
const stepColumns: TableColumn<ScenarioRunStep>[] = [
{ key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 },
@@ -423,6 +447,52 @@ export function RunDetailPage() {
)}
</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>