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,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user