From 86f9ac560360b4a21f8d038452e76431bcaf4e1e Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 21 Apr 2026 15:58:04 +0300 Subject: [PATCH] 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 --- client/src/App.tsx | 5 +- client/src/api/client.ts | 73 ++- client/src/api/types.ts | 17 + client/src/i18n/locales/en.json | 16 + .../pages/credential/EditCredentialPage.tsx | 6 +- .../pages/environment/EditEnvironmentPage.tsx | 4 +- client/src/pages/run/AllRunsPage.tsx | 7 +- client/src/pages/run/RunDetailPage.tsx | 78 +++- client/src/pages/run/RunsPage.tsx | 35 +- .../src/pages/scenario/CreateScenarioPage.tsx | 11 +- .../src/pages/scenario/EditScenarioPage.tsx | 9 +- client/src/pages/scenario/EditStepPage.tsx | 4 +- .../src/pages/scenario/ScenarioDetailPage.tsx | 160 ++++++- client/src/pages/scenario/ScenariosPage.tsx | 23 +- client/src/pages/session/SessionsPage.tsx | 17 +- client/src/pages/snippet/EditSnippetPage.tsx | 4 +- client/src/ui/Table/Table.tsx | 19 +- server/src/scenario/scenario.service.ts | 5 +- server/test/exec-context-files.spec.ts | 419 +++++++++++++++++ server/test/file.controller.spec.ts | 421 ++++++++++++++++++ 20 files changed, 1237 insertions(+), 96 deletions(-) create mode 100644 server/test/exec-context-files.spec.ts create mode 100644 server/test/file.controller.spec.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index fc0e579..3718767 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -90,10 +90,7 @@ const SnippetDetailPage = lazy(() => import('./pages/snippet/SnippetDetailPage').then((m) => ({ default: m.SnippetDetailPage })), ); -const NAV: ( - | { path: string; labelKey: string; Icon: LucideIcon } - | { separator: true } -)[] = [ +const NAV: ({ path: string; labelKey: string; Icon: LucideIcon } | { separator: true })[] = [ { path: '/runs', labelKey: 'nav.runs', Icon: Activity }, { path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor }, { separator: true }, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 7cfcfd7..e49e710 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -2,6 +2,8 @@ import type { PaginatedResponse, Credential, Environment, + FileMetadata, + FileListResponse, ScenarioCredential, Session, Scenario, @@ -23,9 +25,14 @@ const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/ async function request(path: string, init?: RequestInit): Promise { let res: Response; try { + const headers = new Headers(init?.headers); + if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + res = await fetch(`${BASE_URL}${path}`, { - headers: { 'Content-Type': 'application/json', ...init?.headers }, ...init, + headers, }); } catch (err) { const message = (err as Error).message || 'Network request failed'; @@ -139,7 +146,10 @@ export const environments = { body: JSON.stringify({ name, description, data }), }); }, - update(id: string, patch: Partial>): Promise { + update( + id: string, + patch: Partial>, + ): Promise { return request(`/environments/${id}`, { method: 'PATCH', body: JSON.stringify(patch), @@ -162,7 +172,12 @@ export const environments = { // ── Sessions ────────────────────────────────────────────────────────────────── export const sessions = { - list(page = 1, limit = 50, orderBy = 'id', orderDir: 'ASC' | 'DESC' = 'DESC'): Promise> { + list( + page = 1, + limit = 50, + orderBy = 'id', + orderDir: 'ASC' | 'DESC' = 'DESC', + ): Promise> { return request(`/sessions?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`); }, get(id: string): Promise { @@ -182,7 +197,9 @@ export const scenarios = { orderBy = 'updatedAt', orderDir: 'ASC' | 'DESC' = 'DESC', ): Promise> { - return request(`/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`); + return request( + `/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`, + ); }, get(id: string): Promise { return request(`/scenarios/${id}`); @@ -193,7 +210,10 @@ export const scenarios = { body: JSON.stringify({ name, description, environmentId }), }); }, - update(id: string, patch: Partial>): Promise { + update( + id: string, + patch: Partial>, + ): Promise { return request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(patch), @@ -252,11 +272,7 @@ export const runs = { status?: string, orderBy = 'createdAt', orderDir: 'ASC' | 'DESC' = 'DESC', - ): Promise< - PaginatedResponse< - ScenarioRun & { scenario: { id: string; name: string } } - > - > { + ): Promise> { const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir }); if (status) q.set('status', status); return request(`/scenarios/runs?${q}`); @@ -268,7 +284,9 @@ export const runs = { orderBy = 'createdAt', orderDir: 'ASC' | 'DESC' = 'DESC', ): Promise> { - return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`); + return request( + `/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`, + ); }, get(scenarioId: string, runId: string, q?: string): Promise { const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''; @@ -329,3 +347,36 @@ export const steps = { return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' }); }, }; + +// ── Scenario Files ──────────────────────────────────────────────────────── + +export const scenarioFiles = { + upload(scenarioId: string, file: File, expiresAt?: Date): Promise { + const form = new FormData(); + form.append('file', file); + if (expiresAt) form.append('expiresAt', expiresAt.toISOString()); + return request(`/scenarios/${scenarioId}/files`, { + method: 'POST', + body: form, + }); + }, + list(scenarioId: string, limit = 20, offset = 0): Promise { + return request(`/scenarios/${scenarioId}/files?limit=${limit}&offset=${offset}`); + }, + /** Returns a URL that can be used as an for direct download. */ + contentUrl(scenarioId: string, fileId: string): string { + return `${BASE_URL}/scenarios/${scenarioId}/files/${fileId}/content`; + }, +}; + +// ── Run Files ───────────────────────────────────────────────────────────── + +export const runFiles = { + list(scenarioId: string, runId: string, limit = 20, offset = 0): Promise { + return request(`/scenarios/${scenarioId}/runs/${runId}/files?limit=${limit}&offset=${offset}`); + }, + /** Returns a URL that can be used as an for direct download. */ + contentUrl(scenarioId: string, runId: string, fileId: string): string { + return `${BASE_URL}/scenarios/${scenarioId}/runs/${runId}/files/${fileId}/content`; + }, +}; diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 880bfee..689a382 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -143,3 +143,20 @@ export interface ScenarioRunDetail extends ScenarioRun { stepRuns: ScenarioRunStep[]; logs: ScenarioRunLog[]; } + +// ── Files ───────────────────────────────────────────────────────────────── + +export interface FileMetadata { + id: string; + name: string; + mimeType: string; + size: number; + sha256: string; + expiresAt: string | null; + createdAt: string; +} + +export interface FileListResponse { + items: FileMetadata[]; + total: number; +} diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index b200998..3e27dea 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -295,5 +295,21 @@ "action_export": "Export", "action_import": "Import", "import_error": "Failed to import snippet" + }, + "files": { + "title": "Files", + "upload": "Upload", + "uploadTitle": "Upload File", + "uploadSuccess": "File uploaded successfully", + "name": "Name", + "mimeType": "MIME Type", + "size": "Size", + "expiresAt": "Expires At", + "expiresAtLabel": "Expires At (optional)", + "createdAt": "Created", + "download": "Download", + "empty": "No files uploaded.", + "totalCount": "Total: {{count}} file(s)", + "artifactsTitle": "Artifacts" } } diff --git a/client/src/pages/credential/EditCredentialPage.tsx b/client/src/pages/credential/EditCredentialPage.tsx index 71d544e..e78f71f 100644 --- a/client/src/pages/credential/EditCredentialPage.tsx +++ b/client/src/pages/credential/EditCredentialPage.tsx @@ -16,7 +16,11 @@ export function EditCredentialPage() { const [credential, setCredential] = useState(null); const [name, setName] = useState(''); - usePageTitle(credential ? `${t('credentials.edit_title')} — ${credential.name}` : t('credentials.edit_title')); + usePageTitle( + credential + ? `${t('credentials.edit_title')} — ${credential.name}` + : t('credentials.edit_title'), + ); const [data, setData] = useState(''); const [nameError, setNameError] = useState(''); const [dataError, setDataError] = useState(''); diff --git a/client/src/pages/environment/EditEnvironmentPage.tsx b/client/src/pages/environment/EditEnvironmentPage.tsx index c4143a6..077570b 100644 --- a/client/src/pages/environment/EditEnvironmentPage.tsx +++ b/client/src/pages/environment/EditEnvironmentPage.tsx @@ -16,7 +16,9 @@ export function EditEnvironmentPage() { const [env, setEnv] = useState(null); const [name, setName] = useState(''); - usePageTitle(env ? `${t('environments.edit_title')} — ${env.name}` : t('environments.edit_title')); + usePageTitle( + env ? `${t('environments.edit_title')} — ${env.name}` : t('environments.edit_title'), + ); const [description, setDescription] = useState(''); const [dataJson, setDataJson] = useState('{}'); const [nameError, setNameError] = useState(''); diff --git a/client/src/pages/run/AllRunsPage.tsx b/client/src/pages/run/AllRunsPage.tsx index d877bfb..a86a70e 100644 --- a/client/src/pages/run/AllRunsPage.tsx +++ b/client/src/pages/run/AllRunsPage.tsx @@ -47,12 +47,7 @@ export function AllRunsPage() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); - const { - data, - isLoading, - error, - refetch, - } = useQuery({ + const { data, isLoading, error, refetch } = useQuery({ queryKey: ['runs', sortBy, sortDir, page, pageSize], queryFn: () => runs.listAll(page, pageSize, undefined, sortBy, sortDir === 'asc' ? 'ASC' : 'DESC'), diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx index a66d3db..d3ed32c 100644 --- a/client/src/pages/run/RunDetailPage.tsx +++ b/client/src/pages/run/RunDetailPage.tsx @@ -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>(new Set()); + const [artifacts, setArtifacts] = useState([]); + const [artifactsTotal, setArtifactsTotal] = useState(0); + const [artifactsLoading, setArtifactsLoading] = useState(false); const logSearchRef = useRef(''); const LOG_PAGE_SIZE = 25; const pollRef = useRef | 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[] = [ { key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 }, @@ -423,6 +447,52 @@ export function RunDetailPage() { )} )} + + {(artifactsTotal > 0 || artifactsLoading) && ( + + )} )} diff --git a/client/src/pages/run/RunsPage.tsx b/client/src/pages/run/RunsPage.tsx index ba9f321..b2da28c 100644 --- a/client/src/pages/run/RunsPage.tsx +++ b/client/src/pages/run/RunsPage.tsx @@ -4,12 +4,7 @@ import { useTranslation } from 'react-i18next'; import { usePageTitle } from '../../hooks/usePageTitle'; import { Play, ClipboardList, Activity } from 'lucide-react'; import { environments, scenarios, runs } from '../../api'; -import type { - Environment, - Scenario, - ScenarioRun, - ScenarioRunStatus, -} from '../../api'; +import type { Environment, Scenario, ScenarioRun, ScenarioRunStatus } from '../../api'; import { AutoRefreshIndicator, Badge, @@ -86,16 +81,16 @@ export function RunsPage() { scenarios.get(id!), runs.list(id!, p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'), ]) - .then(([sc, res]) => { - setScenario(sc); - setItems(res.data); - setTotal(res.total); - setError(null); - setPulseKey((k) => k + 1); - hasDataRef.current = true; - }) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); + .then(([sc, res]) => { + setScenario(sc); + setItems(res.data); + setTotal(res.total); + setError(null); + setPulseKey((k) => k + 1); + hasDataRef.current = true; + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); }, [id], ); @@ -109,7 +104,6 @@ export function RunsPage() { return () => { if (pollRef.current) clearInterval(pollRef.current); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [load]); const handleSort = (key: string, dir: SortDirection) => { @@ -194,7 +188,12 @@ export function RunsPage() { ]} />
- load(sortBy, sortDir, page, pageSize)} /> + load(sortBy, sortDir, page, pageSize)} + />
+ + {/* ── Files section ───────────────────────────────────────────────── */} +
+
+

{t('files.title')}

+ +
+ + + + 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 ? : '—'), + }, + { + key: 'createdAt', + header: t('files.createdAt'), + render: (f) => , + }, + { + key: 'download', + header: '', + render: (f) => ( + + {t('files.download')} + + ), + }, + ] satisfies TableColumn[] + } + emptyMessage={t('files.empty')} + /> +

{t('files.totalCount', { count: filesTotal })}

+
+
+ + {/* ── Upload modal ─────────────────────────────────────────────── */} + setUploadOpen(false)} + > +
+ setUploadFile(e.target.files?.[0] ?? null)} + /> + setUploadExpiresAt(e.target.value)} + /> + +
+
)} diff --git a/client/src/pages/scenario/ScenariosPage.tsx b/client/src/pages/scenario/ScenariosPage.tsx index 41697b0..6ba161c 100644 --- a/client/src/pages/scenario/ScenariosPage.tsx +++ b/client/src/pages/scenario/ScenariosPage.tsx @@ -67,20 +67,21 @@ export function ScenariosPage() { scenarios.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'), environments.list(1, 200), ]) - .then(([scenarioRes, envRes]) => { - setItems(scenarioRes?.data ?? []); - setTotal(scenarioRes?.total ?? 0); - setEnvs(envRes?.data ?? []); - }) - .catch((err) => { - toast.error((err as Error).message); - }) - .finally(() => setLoading(false)); - }, [toast]); + .then(([scenarioRes, envRes]) => { + setItems(scenarioRes?.data ?? []); + setTotal(scenarioRes?.total ?? 0); + setEnvs(envRes?.data ?? []); + }) + .catch((err) => { + toast.error((err as Error).message); + }) + .finally(() => setLoading(false)); + }, + [toast], + ); useEffect(() => { load('updatedAt', 'desc', 1, 10); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [load]); const handleSort = (key: string, dir: SortDirection) => { diff --git a/client/src/pages/session/SessionsPage.tsx b/client/src/pages/session/SessionsPage.tsx index 1c57321..978691a 100644 --- a/client/src/pages/session/SessionsPage.tsx +++ b/client/src/pages/session/SessionsPage.tsx @@ -36,7 +36,10 @@ export function SessionsPage() { setLoading(true); sessions .list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC') - .then((res) => { setItems(res.data); setTotal(res.total); }) + .then((res) => { + setItems(res.data); + setTotal(res.total); + }) .finally(() => setLoading(false)); }, []); @@ -64,7 +67,12 @@ export function SessionsPage() { const columns: TableColumn[] = [ { key: 'id', header: t('sessions.col_id'), render: (s) => , width: 60 }, - { key: 'sessionName', header: t('sessions.col_name'), sortable: true, render: (s) => s.sessionName }, + { + key: 'sessionName', + header: t('sessions.col_name'), + sortable: true, + render: (s) => s.sessionName, + }, { key: 'status', header: t('sessions.col_status'), @@ -117,7 +125,10 @@ export function SessionsPage() { total={total} page={page} onPageChange={setPage} - onPageSizeChange={(s) => { setPageSize(s); setPage(1); }} + onPageSizeChange={(s) => { + setPageSize(s); + setPage(1); + }} pageSizeOptions={[10, 25, 50]} onRowClick={(s) => navigate(`/sessions/${s.id}`)} /> diff --git a/client/src/pages/snippet/EditSnippetPage.tsx b/client/src/pages/snippet/EditSnippetPage.tsx index 3f6c3e6..f1f5fa5 100644 --- a/client/src/pages/snippet/EditSnippetPage.tsx +++ b/client/src/pages/snippet/EditSnippetPage.tsx @@ -16,7 +16,9 @@ export function EditSnippetPage() { const [snippet, setSnippet] = useState(null); const [alias, setAlias] = useState(''); - usePageTitle(snippet ? `${t('snippets.edit_title')} — ${snippet.title}` : t('snippets.edit_title')); + usePageTitle( + snippet ? `${t('snippets.edit_title')} — ${snippet.title}` : t('snippets.edit_title'), + ); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [code, setCode] = useState(''); diff --git a/client/src/ui/Table/Table.tsx b/client/src/ui/Table/Table.tsx index 186df2f..7c8503d 100644 --- a/client/src/ui/Table/Table.tsx +++ b/client/src/ui/Table/Table.tsx @@ -76,8 +76,7 @@ export function Table({ const handleSortClick = (key: string) => { if (!onSort) return; - const nextDir: SortDirection = - sortBy === key && sortDir === 'asc' ? 'desc' : 'asc'; + const nextDir: SortDirection = sortBy === key && sortDir === 'asc' ? 'desc' : 'asc'; onSort(key, nextDir); }; @@ -101,19 +100,12 @@ export function Table({ {columns.map((col) => { const isSorted = col.sortable && sortBy === col.key; - const SortIcon = isSorted - ? sortDir === 'asc' - ? ArrowUp - : ArrowDown - : ArrowUpDown; + const SortIcon = isSorted ? (sortDir === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown; return ( ({ {col.sortable && (