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
+146 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, SubmitEvent } from 'react';
import { useEffect, useRef, useState, useCallback, SubmitEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTitle } from '../../hooks/usePageTitle';
@@ -12,12 +12,12 @@ import {
GripVertical,
ClipboardList,
} from 'lucide-react';
import { stringify as yamlStringify } from 'yaml';
import {
environments,
scenarios,
steps,
scenarioCredentials,
scenarioFiles,
credentials as credentialsApi,
} from '../../api';
import type {
@@ -26,6 +26,7 @@ import type {
ScenarioCredential,
Credential,
Environment,
FileMetadata,
} from '../../api';
import {
Breadcrumbs,
@@ -83,6 +84,27 @@ export function ScenarioDetailPage() {
const [includedCredentialIds, setIncludedCredentialIds] = useState<Set<string>>(new Set());
const [isExporting, setIsExporting] = useState(false);
// Files state
const [files, setFiles] = useState<FileMetadata[]>([]);
const [filesTotal, setFilesTotal] = useState(0);
const [filesLoading, setFilesLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadExpiresAt, setUploadExpiresAt] = useState('');
const [uploading, setUploading] = useState(false);
const loadFiles = useCallback(async () => {
if (!id) return;
setFilesLoading(true);
try {
const result = await scenarioFiles.list(id);
setFiles(result.items);
setFilesTotal(result.total);
} finally {
setFilesLoading(false);
}
}, [id]);
useEffect(() => {
if (!id) return;
void scenarios
@@ -92,6 +114,7 @@ export function ScenarioDetailPage() {
setScenarioCreds(s.scenarioCredentials ?? []);
})
.finally(() => setLoading(false));
void loadFiles();
credentialsApi
.list(1, 200)
.then((r) => setAllCredentials(r.data))
@@ -102,7 +125,7 @@ export function ScenarioDetailPage() {
setEnvs(r.data);
})
.catch(() => {});
}, [id]);
}, [id, loadFiles]);
// Default selected env to scenario's linked environment (or first env) once both are loaded
useEffect(() => {
@@ -178,6 +201,28 @@ export function ScenarioDetailPage() {
}
};
async function handleUpload(e: SubmitEvent) {
e.preventDefault();
if (!id || !uploadFile) return;
setUploading(true);
try {
await scenarioFiles.upload(
id,
uploadFile,
uploadExpiresAt ? new Date(uploadExpiresAt) : undefined,
);
setUploadOpen(false);
setUploadFile(null);
setUploadExpiresAt('');
await loadFiles();
toast.success(t('files.uploadSuccess'));
} catch {
// error toast already emitted by API client
} finally {
setUploading(false);
}
}
const handleDeleteStep = async (stepId: string) => {
await steps.remove(id!, stepId);
await reloadScenario();
@@ -427,17 +472,22 @@ export function ScenarioDetailPage() {
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
{ term: t('scenarios.field_name'), detail: scenario.name },
...(scenario.environment
? [{
term: t('scenarios.field_environment'),
detail: (
<a
href={`/environments/${scenario.environment.id}`}
onClick={(e) => { e.preventDefault(); navigate(`/environments/${scenario.environment!.id}`); }}
>
{scenario.environment.name}
</a>
),
}]
? [
{
term: t('scenarios.field_environment'),
detail: (
<a
href={`/environments/${scenario.environment.id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/environments/${scenario.environment!.id}`);
}}
>
{scenario.environment.name}
</a>
),
},
]
: []),
{
term: t('scenarios.field_created'),
@@ -589,6 +639,88 @@ export function ScenarioDetailPage() {
<p className={styles.muted}>{t('scenarios.cred_empty')}</p>
)}
</div>
{/* ── Files section ───────────────────────────────────────────────── */}
<div className={styles.stepsSection}>
<div className={styles.sectionToolbar}>
<h2 className={styles.sectionHeading}>{t('files.title')}</h2>
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Upload size={14} /> {t('files.upload')}
</Button>
</div>
<Card>
<Table<FileMetadata>
loading={filesLoading}
data={files}
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={scenarioFiles.contentUrl(id!, f.id)} download={f.name}>
{t('files.download')}
</a>
),
},
] satisfies TableColumn<FileMetadata>[]
}
emptyMessage={t('files.empty')}
/>
<p>{t('files.totalCount', { count: filesTotal })}</p>
</Card>
</div>
{/* ── Upload modal ─────────────────────────────────────────────── */}
<Modal
open={uploadOpen}
title={t('files.uploadTitle')}
onClose={() => setUploadOpen(false)}
>
<form onSubmit={handleUpload}>
<input
type="file"
required
onChange={(e) => setUploadFile(e.target.files?.[0] ?? null)}
/>
<Input
label={t('files.expiresAtLabel')}
type="datetime-local"
value={uploadExpiresAt}
onChange={(e) => setUploadExpiresAt(e.target.value)}
/>
<Button type="submit" loading={uploading} disabled={!uploadFile}>
{t('files.upload')}
</Button>
</form>
</Modal>
</>
)}