+
+
navigate('/scenarios') },
+ {
+ label: scenario?.name ?? `#${id}`,
+ onClick: () => navigate(`/scenarios/${id}`),
+ },
+ {
+ label: t('runs.title'),
+ onClick: () => navigate(`/scenarios/${id}/runs`),
+ },
+ { label: `#${runId}` },
+ ]}
+ />
+
+
+
+ {error && (
+
+ {error.startsWith('404') ? t('errors.not_found') : error}
+
+ )}
+ {loading &&
{t('runs.loading')}
}
+
+ {run && (
+ <>
+
+
+ {RUN_STATUS_LABEL[run.status]}
+
+ ),
+ },
+ { term: t('runs.field_steps'), detail: run.stepRuns.length },
+ { term: t('runs.field_created'), detail: },
+ { term: t('runs.field_updated'), detail: },
+ ]}
+ />
+
+
+ {run.stepRuns.length > 0 && (
+
+
{t('runs.steps_heading')}
+
s.id}
+ loading={false}
+ emptyMessage=""
+ />
+
+ )}
+
+ {run.logs.length > 0 && (
+
+
{t('runs.logs_heading')}
+
l.id}
+ loading={false}
+ emptyMessage=""
+ />
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/client/src/pages/run/RunsPage.tsx b/client/src/pages/run/RunsPage.tsx
new file mode 100644
index 0000000..3268844
--- /dev/null
+++ b/client/src/pages/run/RunsPage.tsx
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { Play } from 'lucide-react';
+import { scenarios, runs } from '../../api';
+import type { Scenario, ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api';
+import { AutoRefreshIndicator, Badge, Breadcrumbs, Button, Table, Timestamp, type TableColumn } from '../../ui';
+import type { BadgeVariant } from '../../ui';
+import styles from '../Page.module.css';
+
+const STATUS_VARIANT: Record = {
+ pending: 'neutral',
+ in_progress: 'info',
+ pass: 'success',
+ fail: 'error',
+};
+
+const STATUS_LABEL: Record = {
+ pending: 'Pending',
+ in_progress: 'Running',
+ pass: 'Pass',
+ fail: 'Fail',
+};
+
+type RunRow = ScenarioRun & { stepRuns: ScenarioRunStep[] };
+
+export function RunsPage() {
+ const { t } = useTranslation();
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ const [scenario, setScenario] = useState(null);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [pulseKey, setPulseKey] = useState(0);
+ const pollRef = useRef | null>(null);
+
+ const load = useCallback(() => {
+ if (!id) return;
+ setLoading(true);
+ Promise.all([scenarios.get(Number(id)), runs.list(Number(id))])
+ .then(([sc, res]) => {
+ setScenario(sc);
+ setItems(res.data);
+ setPulseKey((k) => k + 1);
+ })
+ .catch((err: Error) => setError(err.message))
+ .finally(() => setLoading(false));
+ }, [id]);
+
+ useEffect(() => {
+ load();
+ pollRef.current = setInterval(load, 10_000);
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ };
+ }, [load]);
+
+ const handleRun = async () => {
+ const run = await scenarios.run(Number(id));
+ navigate(`/scenarios/${id}/runs/${run.id}`);
+ };
+
+ const columns: TableColumn[] = [
+ { key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 },
+ {
+ key: 'status',
+ header: t('runs.col_status'),
+ width: 100,
+ render: (r) => (
+ {STATUS_LABEL[r.status]}
+ ),
+ },
+ {
+ key: 'steps',
+ header: t('runs.col_steps'),
+ width: 80,
+ render: (r) => r.stepRuns.length,
+ },
+ {
+ key: 'created',
+ header: t('runs.col_created'),
+ width: 160,
+ render: (r) => ,
+ },
+ ];
+
+ return (
+
+
+
navigate('/scenarios') },
+ {
+ label: scenario?.name ?? `#${id}`,
+ onClick: () => navigate(`/scenarios/${id}`),
+ },
+ { label: t('runs.title') },
+ ]}
+ />
+
+
+
+
+
+
+ {error &&
{error}
}
+
r.id}
+ loading={loading}
+ emptyMessage={t('runs.empty')}
+ pageSize={20}
+ pageSizeOptions={[10, 20, 50]}
+ onRowClick={(r) => navigate(`/scenarios/${id}/runs/${r.id}`)}
+ />
+
+ );
+}
diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx
index 1ff4c08..5941ad3 100644
--- a/client/src/pages/scenario/ScenarioDetailPage.tsx
+++ b/client/src/pages/scenario/ScenarioDetailPage.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { Play, Pencil, Plus, Trash2 } from 'lucide-react';
+import { Play, Pencil, Plus, History, Trash2 } from 'lucide-react';
import { scenarios, steps } from '../../api';
import type { Scenario, ScenarioStep } from '../../api';
import {
@@ -42,7 +42,8 @@ export function ScenarioDetailPage() {
const handleRun = async () => {
if (!scenario) return;
- await scenarios.run(scenario.id);
+ const run = await scenarios.run(scenario.id);
+ navigate(`/scenarios/${id}/runs/${run.id}`);
};
const handleDelete = async () => {
@@ -122,6 +123,10 @@ export function ScenarioDetailPage() {
{t('scenarios.action_run')}
+