From 1f3a604940a91ba8f0fb6d0d329e2c67891976e5 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Thu, 9 Apr 2026 21:58:27 +0300 Subject: [PATCH] feat(runs): add runs pages, polling, autorefresh indicator, and scheduler fix - add global runs page and per-scenario runs/run-detail pages - run detail page polls every 1s until pass/fail, cleans up on unmount - runs list pages poll every 10s with AutoRefreshIndicator (pulse on data load) - fix scheduler: set run to pass after empty step loop to prevent stuck in_progress - fix table header colors and link cell color for readability - fix play button to navigate to the new run after creation - fix package.json import paths in server for Docker build context --- client/src/App.tsx | 9 +- client/src/api/client.ts | 24 +- client/src/api/types.ts | 35 ++- client/src/i18n/locales/en.json | 30 ++- client/src/pages/Page.module.css | 18 ++ client/src/pages/run/AllRunsPage.tsx | 118 +++++++++ client/src/pages/run/RunDetailPage.tsx | 236 ++++++++++++++++++ client/src/pages/run/RunsPage.tsx | 124 +++++++++ .../src/pages/scenario/ScenarioDetailPage.tsx | 9 +- client/src/pages/scenario/ScenariosPage.tsx | 3 +- .../AutoRefreshIndicator.module.css | 69 +++++ .../AutoRefreshIndicator.tsx | 25 ++ client/src/ui/Table/Table.module.css | 7 +- client/src/ui/index.ts | 3 + package-lock.json | 19 +- server/src/main.ts | 2 +- server/src/mcp/mcp.service.ts | 2 +- .../scenario/scenario-scheduler.service.ts | 8 + server/src/scenario/scenario.controller.ts | 7 + server/src/scenario/scenario.service.ts | 19 ++ 20 files changed, 745 insertions(+), 22 deletions(-) create mode 100644 client/src/pages/run/AllRunsPage.tsx create mode 100644 client/src/pages/run/RunDetailPage.tsx create mode 100644 client/src/pages/run/RunsPage.tsx create mode 100644 client/src/ui/AutoRefreshIndicator/AutoRefreshIndicator.module.css create mode 100644 client/src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 7dfe541..da9d2a0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,6 @@ import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Globe, KeyRound, Monitor, ClipboardList } from 'lucide-react'; +import { Globe, KeyRound, Monitor, ClipboardList, Activity } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import styles from './App.module.css'; import { SidePanel, ThemeSwitcher } from './ui'; @@ -18,12 +18,16 @@ import { CreateScenarioPage } from './pages/scenario/CreateScenarioPage'; import { EditScenarioPage } from './pages/scenario/EditScenarioPage'; import { CreateStepPage } from './pages/scenario/CreateStepPage'; import { EditStepPage } from './pages/scenario/EditStepPage'; +import { RunsPage } from './pages/run/RunsPage'; +import { RunDetailPage } from './pages/run/RunDetailPage'; +import { AllRunsPage } from './pages/run/AllRunsPage'; const NAV: { path: string; labelKey: string; Icon: LucideIcon }[] = [ { path: '/environments', labelKey: 'nav.environments', Icon: Globe }, { path: '/keys', labelKey: 'nav.keys', Icon: KeyRound }, { path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor }, { path: '/scenarios', labelKey: 'nav.scenarios', Icon: ClipboardList }, + { path: '/runs', labelKey: 'nav.runs', Icon: Activity }, ]; export default function App() { @@ -71,7 +75,10 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> + } /> } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 1efb22e..4051308 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -5,6 +5,8 @@ import type { Session, Scenario, ScenarioRun, + ScenarioRunDetail, + ScenarioRunStep, ScenarioStep, } from './types'; @@ -103,17 +105,33 @@ export const scenarios = { run(id: number): Promise { return request(`/scenarios/${id}/run`, { method: 'POST' }); }, - getRun(scenarioId: number, runId: number): Promise { + getRun(scenarioId: number, runId: number): Promise { return request(`/scenarios/${scenarioId}/run/${runId}`); }, - waitForRun(scenarioId: number, runId: number): Promise { + waitForRun(scenarioId: number, runId: number): Promise { return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' }); }, - listRuns(scenarioId: number, page = 1, limit = 20): Promise> { + listRuns(scenarioId: number, page = 1, limit = 20): Promise> { return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); }, }; +// ── Runs ───────────────────────────────────────────────────────────────────── + +export const runs = { + listAll(page = 1, limit = 20, status?: string): Promise> { + const q = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (status) q.set('status', status); + return request(`/scenarios/runs?${q}`); + }, + list(scenarioId: number, page = 1, limit = 20): Promise> { + return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); + }, + get(scenarioId: number, runId: number): Promise { + return request(`/scenarios/${scenarioId}/run/${runId}`); + }, +}; + // ── Scenario Steps ──────────────────────────────────────────────────────────── export interface CreateStepPayload { diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 758411b..565f833 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -68,12 +68,45 @@ export interface Scenario { updatedAt: string; } -export type ScenarioRunStatus = 'pending' | 'running' | 'pass' | 'fail'; +export type ScenarioRunStatus = 'pending' | 'in_progress' | 'pass' | 'fail'; + +export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fail' | 'cancelled'; + +export type LogLevel = 'log' | 'warn' | 'error'; export interface ScenarioRun { id: number; scenarioId: number; + scenario?: Pick; status: ScenarioRunStatus; + stepRuns?: ScenarioRunStep[]; createdAt: string; updatedAt: string; } + +export interface ScenarioRunStep { + id: number; + runId: number; + scenarioStepId: number; + scenarioStep: ScenarioStep; + status: RunStepStatus; + order: number; + description: string | null; + output: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ScenarioRunLog { + id: number; + runId: number; + stepRunId: number | null; + level: LogLevel; + message: string; + createdAt: string; +} + +export interface ScenarioRunDetail extends ScenarioRun { + stepRuns: ScenarioRunStep[]; + logs: ScenarioRunLog[]; +} diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 7353a45..1532c02 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -10,7 +10,8 @@ "environments": "Environments", "keys": "Keys", "sessions": "Sessions", - "scenarios": "Scenarios" + "scenarios": "Scenarios", + "runs": "Runs" }, "environments": { "title": "Environments", @@ -85,6 +86,7 @@ "step_updated": "Updated", "action_add": "New scenario", "action_edit": "Edit", + "action_runs": "Runs", "action_save": "Create", "action_update": "Save changes", "action_cancel": "Cancel", @@ -127,5 +129,31 @@ "form_exec_code_placeholder": "return await page.title();", "form_validate_code": "Validate code", "form_validate_code_placeholder": "return { success: result !== null, description: \"…\" };" + }, + "runs": { + "title": "Runs", + "loading": "Loading…", + "empty": "No runs yet.", + "action_run": "Run scenario", + "col_id": "ID", + "col_scenario": "Scenario", + "col_status": "Status", + "col_steps": "Steps", + "col_created": "Started", + "field_id": "ID", + "field_status": "Status", + "field_steps": "Steps", + "field_created": "Started", + "field_updated": "Updated", + "steps_heading": "Step runs", + "logs_heading": "Logs", + "step_order": "#", + "step_type": "Type", + "step_session": "Session", + "step_status": "Status", + "step_description": "Result", + "log_level": "Level", + "log_message": "Message", + "log_time": "Time" } } diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index 96e6cd8..d0519a8 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -119,6 +119,24 @@ margin-right: calc(-1 * var(--space-1)); } +.pageTitle { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--color-text); +} + +.linkCell { + color: var(--color-link); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.linkCell:hover { + color: var(--color-link-hover); +} + .envCardHeader button:hover { background: color-mix(in srgb, currentColor 15%, transparent) !important; } diff --git a/client/src/pages/run/AllRunsPage.tsx b/client/src/pages/run/AllRunsPage.tsx new file mode 100644 index 0000000..eed53c0 --- /dev/null +++ b/client/src/pages/run/AllRunsPage.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { runs } from '../../api'; +import type { ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api'; +import { AutoRefreshIndicator, Badge, 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 AllRunRow = ScenarioRun & { + stepRuns: ScenarioRunStep[]; + scenario: { id: number; name: string }; +}; + +export function AllRunsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + 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 = () => { + runs + .listAll() + .then((res) => { + setItems(res.data as AllRunRow[]); + setPulseKey((k) => k + 1); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + setLoading(true); + load(); + pollRef.current = setInterval(load, 10_000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + const columns: TableColumn[] = [ + { key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 }, + { + key: 'scenario', + header: t('runs.col_scenario'), + render: (r) => ( + { + e.stopPropagation(); + navigate(`/scenarios/${r.scenario.id}`); + }} + > + {r.scenario.name} + + ), + }, + { + key: 'status', + header: t('runs.col_status'), + width: 110, + 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 ( +
+
+

{t('runs.title')}

+ +
+ + {error &&

{error}

} + r.id} + loading={loading} + emptyMessage={t('runs.empty')} + pageSize={20} + pageSizeOptions={[10, 20, 50]} + onRowClick={(r) => navigate(`/scenarios/${r.scenarioId}/runs/${r.id}`)} + /> + + ); +} diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx new file mode 100644 index 0000000..8ab0c5a --- /dev/null +++ b/client/src/pages/run/RunDetailPage.tsx @@ -0,0 +1,236 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { runs } from '../../api'; +import type { + Scenario, + ScenarioRunDetail, + ScenarioRunStep, + ScenarioRunLog, + ScenarioRunStatus, + RunStepStatus, + LogLevel, +} from '../../api'; +import { scenarios } from '../../api'; +import { + AutoRefreshIndicator, + Badge, + Breadcrumbs, + Card, + DescriptionList, + Notification, + Table, + Timestamp, + type TableColumn, +} from '../../ui'; +import type { BadgeVariant } from '../../ui'; +import styles from '../Page.module.css'; + +const RUN_STATUS_VARIANT: Record = { + pending: 'neutral', + in_progress: 'info', + pass: 'success', + fail: 'error', +}; + +const RUN_STATUS_LABEL: Record = { + pending: 'Pending', + in_progress: 'Running', + pass: 'Pass', + fail: 'Fail', +}; + +const STEP_STATUS_VARIANT: Record = { + waiting: 'neutral', + pending: 'neutral', + in_progress: 'info', + pass: 'success', + fail: 'error', + cancelled: 'warning', +}; + +const LOG_LEVEL_VARIANT: Record = { + log: 'neutral', + warn: 'warning', + error: 'error', +}; + +export function RunDetailPage() { + const { t } = useTranslation(); + const { id, runId } = useParams<{ id: string; runId: string }>(); + const navigate = useNavigate(); + + const [scenario, setScenario] = useState(null); + const [run, setRun] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [polling, setPolling] = useState(false); + const [pulseKey, setPulseKey] = useState(0); + const pollRef = useRef | null>(null); + + const FINAL: ScenarioRunStatus[] = ['pass', 'fail']; + + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + setPolling(false); + } + }; + + useEffect(() => { + if (!id || !runId) return; + let cancelled = false; + + Promise.all([scenarios.get(Number(id)), runs.get(Number(id), Number(runId))]) + .then(([sc, r]) => { + if (cancelled) return; + setScenario(sc); + setRun(r); + if (!FINAL.includes(r.status)) { + setPolling(true); + pollRef.current = setInterval(async () => { + try { + const updated = await runs.get(Number(id), Number(runId)); + if (cancelled) return; + setRun(updated); + setPulseKey((k) => k + 1); + if (FINAL.includes(updated.status)) stopPolling(); + } catch { + stopPolling(); + } + }, 1000); + } + }) + .catch((err: Error) => { if (!cancelled) setError(err.message); }) + .finally(() => { if (!cancelled) setLoading(false); }); + + return () => { + cancelled = true; + stopPolling(); + }; + }, [id, runId]); + + const stepColumns: TableColumn[] = [ + { key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 }, + { + key: 'type', + header: t('runs.step_type'), + width: 80, + render: (s) => {s.scenarioStep?.type ?? '–'}, + }, + { + key: 'session', + header: t('runs.step_session'), + render: (s) => s.scenarioStep?.sessionName ?? '–', + }, + { + key: 'status', + header: t('runs.step_status'), + width: 110, + render: (s) => ( + {s.status.replace('_', ' ')} + ), + }, + { + key: 'description', + header: t('runs.step_description'), + render: (s) => s.description ?? , + }, + ]; + + const logColumns: TableColumn[] = [ + { + key: 'level', + header: t('runs.log_level'), + width: 70, + render: (l) => {l.level}, + }, + { key: 'message', header: t('runs.log_message'), render: (l) => l.message }, + { + key: 'time', + header: t('runs.log_time'), + width: 160, + render: (l) => , + }, + ]; + + return ( +
+
+ 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')} +