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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user