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
+1 -4
View File
@@ -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 },
+62 -11
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<Pick<Environment, 'name' | 'description' | 'data'>>): Promise<Environment> {
update(
id: string,
patch: Partial<Pick<Environment, 'name' | 'description' | 'data'>>,
): Promise<Environment> {
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<PaginatedResponse<Session>> {
list(
page = 1,
limit = 50,
orderBy = 'id',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<PaginatedResponse<Session>> {
return request(`/sessions?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
},
get(id: string): Promise<Session> {
@@ -182,7 +197,9 @@ export const scenarios = {
orderBy = 'updatedAt',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<PaginatedResponse<Scenario>> {
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<Scenario & { steps: ScenarioStep[] }> {
return request(`/scenarios/${id}`);
@@ -193,7 +210,10 @@ export const scenarios = {
body: JSON.stringify({ name, description, environmentId }),
});
},
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>): Promise<Scenario> {
update(
id: string,
patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>,
): Promise<Scenario> {
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<PaginatedResponse<ScenarioRun & { scenario: { id: string; name: string } }>> {
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<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
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<ScenarioRunDetail> {
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<FileMetadata> {
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<FileListResponse> {
return request(`/scenarios/${scenarioId}/files?limit=${limit}&offset=${offset}`);
},
/** Returns a URL that can be used as an <a href> 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<FileListResponse> {
return request(`/scenarios/${scenarioId}/runs/${runId}/files?limit=${limit}&offset=${offset}`);
},
/** Returns a URL that can be used as an <a href> for direct download. */
contentUrl(scenarioId: string, runId: string, fileId: string): string {
return `${BASE_URL}/scenarios/${scenarioId}/runs/${runId}/files/${fileId}/content`;
},
};
+17
View File
@@ -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;
}
+16
View File
@@ -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"
}
}
@@ -16,7 +16,11 @@ export function EditCredentialPage() {
const [credential, setCredential] = useState<Credential | null>(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('');
@@ -16,7 +16,9 @@ export function EditEnvironmentPage() {
const [env, setEnv] = useState<Environment | null>(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('');
+1 -6
View File
@@ -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'),
+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>
+17 -18
View File
@@ -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() {
]}
/>
<div className={styles.toolbarActions}>
<AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={() => load(sortBy, sortDir, page, pageSize)} />
<AutoRefreshIndicator
active
pulseKey={pulseKey}
error={!!error}
onClick={() => load(sortBy, sortDir, page, pageSize)}
/>
<Button size="sm" onClick={() => setRunModalOpen(true)}>
<Play size={14} />
{t('runs.action_run')}
@@ -22,7 +22,10 @@ export function CreateScenarioPage() {
const [saving, setSaving] = useState(false);
useEffect(() => {
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
environments
.list(1, 200)
.then((r) => setEnvs(r?.data ?? []))
.catch(() => {});
}, []);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
@@ -33,7 +36,11 @@ export function CreateScenarioPage() {
}
setSaving(true);
try {
const scenario = await scenarios.create(name.trim(), description.trim() || undefined, environmentId || undefined);
const scenario = await scenarios.create(
name.trim(),
description.trim() || undefined,
environmentId || undefined,
);
toast.success(t('scenarios.created'));
navigate(`/scenarios/${scenario.id}`);
} catch (err) {
@@ -16,7 +16,9 @@ export function EditScenarioPage() {
const [scenario, setScenario] = useState<Scenario | null>(null);
const [name, setName] = useState('');
usePageTitle(scenario ? `${t('scenarios.edit_title')}${scenario.name}` : t('scenarios.edit_title'));
usePageTitle(
scenario ? `${t('scenarios.edit_title')}${scenario.name}` : t('scenarios.edit_title'),
);
const [description, setDescription] = useState('');
const [environmentId, setEnvironmentId] = useState<string>('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
@@ -37,7 +39,10 @@ export function EditScenarioPage() {
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
})
.finally(() => setLoading(false));
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
environments
.list(1, 200)
.then((r) => setEnvs(r?.data ?? []))
.catch(() => {});
}, [id]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
+3 -1
View File
@@ -16,7 +16,9 @@ export function EditStepPage() {
const [scenario, setScenario] = useState<Scenario | null>(null);
const [step, setStep] = useState<ScenarioStep | null>(null);
usePageTitle(step ? t('steps.edit_title', { order: step.order }) : t('steps.edit_title', { order: '' }));
usePageTitle(
step ? t('steps.edit_title', { order: step.order }) : t('steps.edit_title', { order: '' }),
);
const [title, setTitle] = useState('');
const [execCode, setExecCode] = useState('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
+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>
</>
)}
+12 -11
View File
@@ -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) => {
+14 -3
View File
@@ -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<Session>[] = [
{ key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, 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}`)}
/>
+3 -1
View File
@@ -16,7 +16,9 @@ export function EditSnippetPage() {
const [snippet, setSnippet] = useState<Snippet | null>(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('');
+4 -15
View File
@@ -76,8 +76,7 @@ export function Table<T>({
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<T>({
<tr>
{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 (
<th
key={col.key}
className={[
styles.th,
col.sortable ? styles.thSortable : '',
]
className={[styles.th, col.sortable ? styles.thSortable : '']
.filter(Boolean)
.join(' ')}
style={{
@@ -127,10 +119,7 @@ export function Table<T>({
{col.sortable && (
<SortIcon
size={12}
className={[
styles.sortIcon,
isSorted ? styles.sortIconActive : '',
]
className={[styles.sortIcon, isSorted ? styles.sortIconActive : '']
.filter(Boolean)
.join(' ')}
aria-hidden="true"