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
This commit is contained in:
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
|
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 type { LucideIcon } from 'lucide-react';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
import { SidePanel, ThemeSwitcher } from './ui';
|
import { SidePanel, ThemeSwitcher } from './ui';
|
||||||
@@ -18,12 +18,16 @@ import { CreateScenarioPage } from './pages/scenario/CreateScenarioPage';
|
|||||||
import { EditScenarioPage } from './pages/scenario/EditScenarioPage';
|
import { EditScenarioPage } from './pages/scenario/EditScenarioPage';
|
||||||
import { CreateStepPage } from './pages/scenario/CreateStepPage';
|
import { CreateStepPage } from './pages/scenario/CreateStepPage';
|
||||||
import { EditStepPage } from './pages/scenario/EditStepPage';
|
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 }[] = [
|
const NAV: { path: string; labelKey: string; Icon: LucideIcon }[] = [
|
||||||
{ path: '/environments', labelKey: 'nav.environments', Icon: Globe },
|
{ path: '/environments', labelKey: 'nav.environments', Icon: Globe },
|
||||||
{ path: '/keys', labelKey: 'nav.keys', Icon: KeyRound },
|
{ path: '/keys', labelKey: 'nav.keys', Icon: KeyRound },
|
||||||
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
||||||
{ path: '/scenarios', labelKey: 'nav.scenarios', Icon: ClipboardList },
|
{ path: '/scenarios', labelKey: 'nav.scenarios', Icon: ClipboardList },
|
||||||
|
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -71,7 +75,10 @@ export default function App() {
|
|||||||
<Route path="/scenarios/:id/edit" element={<EditScenarioPage />} />
|
<Route path="/scenarios/:id/edit" element={<EditScenarioPage />} />
|
||||||
<Route path="/scenarios/:id/steps/new" element={<CreateStepPage />} />
|
<Route path="/scenarios/:id/steps/new" element={<CreateStepPage />} />
|
||||||
<Route path="/scenarios/:id/steps/:stepId/edit" element={<EditStepPage />} />
|
<Route path="/scenarios/:id/steps/:stepId/edit" element={<EditStepPage />} />
|
||||||
|
<Route path="/scenarios/:id/runs" element={<RunsPage />} />
|
||||||
|
<Route path="/scenarios/:id/runs/:runId" element={<RunDetailPage />} />
|
||||||
<Route path="/scenarios/:id" element={<ScenarioDetailPage />} />
|
<Route path="/scenarios/:id" element={<ScenarioDetailPage />} />
|
||||||
|
<Route path="/runs" element={<AllRunsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/scenarios" replace />} />
|
<Route path="*" element={<Navigate to="/scenarios" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
Session,
|
Session,
|
||||||
Scenario,
|
Scenario,
|
||||||
ScenarioRun,
|
ScenarioRun,
|
||||||
|
ScenarioRunDetail,
|
||||||
|
ScenarioRunStep,
|
||||||
ScenarioStep,
|
ScenarioStep,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -103,17 +105,33 @@ export const scenarios = {
|
|||||||
run(id: number): Promise<ScenarioRun> {
|
run(id: number): Promise<ScenarioRun> {
|
||||||
return request(`/scenarios/${id}/run`, { method: 'POST' });
|
return request(`/scenarios/${id}/run`, { method: 'POST' });
|
||||||
},
|
},
|
||||||
getRun(scenarioId: number, runId: number): Promise<ScenarioRun> {
|
getRun(scenarioId: number, runId: number): Promise<ScenarioRunDetail> {
|
||||||
return request(`/scenarios/${scenarioId}/run/${runId}`);
|
return request(`/scenarios/${scenarioId}/run/${runId}`);
|
||||||
},
|
},
|
||||||
waitForRun(scenarioId: number, runId: number): Promise<ScenarioRun> {
|
waitForRun(scenarioId: number, runId: number): Promise<ScenarioRunDetail> {
|
||||||
return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' });
|
return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' });
|
||||||
},
|
},
|
||||||
listRuns(scenarioId: number, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun>> {
|
listRuns(scenarioId: number, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Runs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const runs = {
|
||||||
|
listAll(page = 1, limit = 20, status?: string): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: number; name: string } }>> {
|
||||||
|
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<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||||
|
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||||
|
},
|
||||||
|
get(scenarioId: number, runId: number): Promise<ScenarioRunDetail> {
|
||||||
|
return request(`/scenarios/${scenarioId}/run/${runId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ── Scenario Steps ────────────────────────────────────────────────────────────
|
// ── Scenario Steps ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface CreateStepPayload {
|
export interface CreateStepPayload {
|
||||||
|
|||||||
+34
-1
@@ -68,12 +68,45 @@ export interface Scenario {
|
|||||||
updatedAt: string;
|
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 {
|
export interface ScenarioRun {
|
||||||
id: number;
|
id: number;
|
||||||
scenarioId: number;
|
scenarioId: number;
|
||||||
|
scenario?: Pick<Scenario, 'id' | 'name'>;
|
||||||
status: ScenarioRunStatus;
|
status: ScenarioRunStatus;
|
||||||
|
stepRuns?: ScenarioRunStep[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"environments": "Environments",
|
"environments": "Environments",
|
||||||
"keys": "Keys",
|
"keys": "Keys",
|
||||||
"sessions": "Sessions",
|
"sessions": "Sessions",
|
||||||
"scenarios": "Scenarios"
|
"scenarios": "Scenarios",
|
||||||
|
"runs": "Runs"
|
||||||
},
|
},
|
||||||
"environments": {
|
"environments": {
|
||||||
"title": "Environments",
|
"title": "Environments",
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
"step_updated": "Updated",
|
"step_updated": "Updated",
|
||||||
"action_add": "New scenario",
|
"action_add": "New scenario",
|
||||||
"action_edit": "Edit",
|
"action_edit": "Edit",
|
||||||
|
"action_runs": "Runs",
|
||||||
"action_save": "Create",
|
"action_save": "Create",
|
||||||
"action_update": "Save changes",
|
"action_update": "Save changes",
|
||||||
"action_cancel": "Cancel",
|
"action_cancel": "Cancel",
|
||||||
@@ -127,5 +129,31 @@
|
|||||||
"form_exec_code_placeholder": "return await page.title();",
|
"form_exec_code_placeholder": "return await page.title();",
|
||||||
"form_validate_code": "Validate code",
|
"form_validate_code": "Validate code",
|
||||||
"form_validate_code_placeholder": "return { success: result !== null, description: \"…\" };"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,24 @@
|
|||||||
margin-right: calc(-1 * var(--space-1));
|
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 {
|
.envCardHeader button:hover {
|
||||||
background: color-mix(in srgb, currentColor 15%, transparent) !important;
|
background: color-mix(in srgb, currentColor 15%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ScenarioRunStatus, BadgeVariant> = {
|
||||||
|
pending: 'neutral',
|
||||||
|
in_progress: 'info',
|
||||||
|
pass: 'success',
|
||||||
|
fail: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<ScenarioRunStatus, string> = {
|
||||||
|
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<AllRunRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pulseKey, setPulseKey] = useState(0);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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<AllRunRow>[] = [
|
||||||
|
{ key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 },
|
||||||
|
{
|
||||||
|
key: 'scenario',
|
||||||
|
header: t('runs.col_scenario'),
|
||||||
|
render: (r) => (
|
||||||
|
<span
|
||||||
|
className={styles.linkCell}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/scenarios/${r.scenario.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.scenario.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: t('runs.col_status'),
|
||||||
|
width: 110,
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => <Timestamp value={r.createdAt} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.pageToolbar}>
|
||||||
|
<h1 className={styles.pageTitle}>{t('runs.title')}</h1>
|
||||||
|
<AutoRefreshIndicator active pulseKey={pulseKey} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage={t('runs.empty')}
|
||||||
|
pageSize={20}
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
onRowClick={(r) => navigate(`/scenarios/${r.scenarioId}/runs/${r.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ScenarioRunStatus, BadgeVariant> = {
|
||||||
|
pending: 'neutral',
|
||||||
|
in_progress: 'info',
|
||||||
|
pass: 'success',
|
||||||
|
fail: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RUN_STATUS_LABEL: Record<ScenarioRunStatus, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'Running',
|
||||||
|
pass: 'Pass',
|
||||||
|
fail: 'Fail',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_STATUS_VARIANT: Record<RunStepStatus, BadgeVariant> = {
|
||||||
|
waiting: 'neutral',
|
||||||
|
pending: 'neutral',
|
||||||
|
in_progress: 'info',
|
||||||
|
pass: 'success',
|
||||||
|
fail: 'error',
|
||||||
|
cancelled: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOG_LEVEL_VARIANT: Record<LogLevel, BadgeVariant> = {
|
||||||
|
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<Scenario | null>(null);
|
||||||
|
const [run, setRun] = useState<ScenarioRunDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [polling, setPolling] = useState(false);
|
||||||
|
const [pulseKey, setPulseKey] = useState(0);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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<ScenarioRunStep>[] = [
|
||||||
|
{ key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 },
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: t('runs.step_type'),
|
||||||
|
width: 80,
|
||||||
|
render: (s) => <Badge variant="neutral">{s.scenarioStep?.type ?? '–'}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'session',
|
||||||
|
header: t('runs.step_session'),
|
||||||
|
render: (s) => s.scenarioStep?.sessionName ?? '–',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: t('runs.step_status'),
|
||||||
|
width: 110,
|
||||||
|
render: (s) => (
|
||||||
|
<Badge variant={STEP_STATUS_VARIANT[s.status]}>{s.status.replace('_', ' ')}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
header: t('runs.step_description'),
|
||||||
|
render: (s) => s.description ?? <span className={styles.muted}>–</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const logColumns: TableColumn<ScenarioRunLog>[] = [
|
||||||
|
{
|
||||||
|
key: 'level',
|
||||||
|
header: t('runs.log_level'),
|
||||||
|
width: 70,
|
||||||
|
render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'message', header: t('runs.log_message'), render: (l) => l.message },
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
header: t('runs.log_time'),
|
||||||
|
width: 160,
|
||||||
|
render: (l) => <Timestamp value={l.createdAt} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.pageToolbar}>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: t('scenarios.title'), onClick: () => navigate('/scenarios') },
|
||||||
|
{
|
||||||
|
label: scenario?.name ?? `#${id}`,
|
||||||
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('runs.title'),
|
||||||
|
onClick: () => navigate(`/scenarios/${id}/runs`),
|
||||||
|
},
|
||||||
|
{ label: `#${runId}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<AutoRefreshIndicator active={polling} pulseKey={pulseKey} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Notification variant="error">
|
||||||
|
{error.startsWith('404') ? t('errors.not_found') : error}
|
||||||
|
</Notification>
|
||||||
|
)}
|
||||||
|
{loading && <p className={styles.muted}>{t('runs.loading')}</p>}
|
||||||
|
|
||||||
|
{run && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<DescriptionList
|
||||||
|
layout="comfortable"
|
||||||
|
items={[
|
||||||
|
{ term: t('runs.field_id'), detail: run.id },
|
||||||
|
{
|
||||||
|
term: t('runs.field_status'),
|
||||||
|
detail: (
|
||||||
|
<Badge variant={RUN_STATUS_VARIANT[run.status]}>
|
||||||
|
{RUN_STATUS_LABEL[run.status]}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ term: t('runs.field_steps'), detail: run.stepRuns.length },
|
||||||
|
{ term: t('runs.field_created'), detail: <Timestamp value={run.createdAt} /> },
|
||||||
|
{ term: t('runs.field_updated'), detail: <Timestamp value={run.updatedAt} /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{run.stepRuns.length > 0 && (
|
||||||
|
<div className={styles.stepsSection}>
|
||||||
|
<h2 className={styles.sectionHeading}>{t('runs.steps_heading')}</h2>
|
||||||
|
<Table
|
||||||
|
columns={stepColumns}
|
||||||
|
data={run.stepRuns}
|
||||||
|
rowKey={(s) => s.id}
|
||||||
|
loading={false}
|
||||||
|
emptyMessage=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{run.logs.length > 0 && (
|
||||||
|
<div className={styles.stepsSection}>
|
||||||
|
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
|
||||||
|
<Table
|
||||||
|
columns={logColumns}
|
||||||
|
data={run.logs}
|
||||||
|
rowKey={(l) => l.id}
|
||||||
|
loading={false}
|
||||||
|
emptyMessage=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ScenarioRunStatus, BadgeVariant> = {
|
||||||
|
pending: 'neutral',
|
||||||
|
in_progress: 'info',
|
||||||
|
pass: 'success',
|
||||||
|
fail: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<ScenarioRunStatus, string> = {
|
||||||
|
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<Scenario | null>(null);
|
||||||
|
const [items, setItems] = useState<RunRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pulseKey, setPulseKey] = useState(0);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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<RunRow>[] = [
|
||||||
|
{ key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: t('runs.col_status'),
|
||||||
|
width: 100,
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => <Timestamp value={r.createdAt} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.pageToolbar}>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: t('scenarios.title'), onClick: () => navigate('/scenarios') },
|
||||||
|
{
|
||||||
|
label: scenario?.name ?? `#${id}`,
|
||||||
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
|
},
|
||||||
|
{ label: t('runs.title') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className={styles.toolbarActions}>
|
||||||
|
<AutoRefreshIndicator active pulseKey={pulseKey} />
|
||||||
|
<Button size="sm" onClick={handleRun}>
|
||||||
|
<Play size={14} />
|
||||||
|
{t('runs.action_run')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage={t('runs.empty')}
|
||||||
|
pageSize={20}
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
onRowClick={(r) => navigate(`/scenarios/${id}/runs/${r.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { scenarios, steps } from '../../api';
|
||||||
import type { Scenario, ScenarioStep } from '../../api';
|
import type { Scenario, ScenarioStep } from '../../api';
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +42,8 @@ export function ScenarioDetailPage() {
|
|||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!scenario) return;
|
if (!scenario) return;
|
||||||
await scenarios.run(scenario.id);
|
const run = await scenarios.run(scenario.id);
|
||||||
|
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
@@ -122,6 +123,10 @@ export function ScenarioDetailPage() {
|
|||||||
<Play size={14} />
|
<Play size={14} />
|
||||||
{t('scenarios.action_run')}
|
{t('scenarios.action_run')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/runs`)}>
|
||||||
|
<History size={14} />
|
||||||
|
{t('scenarios.action_runs')}
|
||||||
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
{t('scenarios.action_edit')}
|
{t('scenarios.action_edit')}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export function ScenariosPage() {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const handleRun = async (id: number) => {
|
const handleRun = async (id: number) => {
|
||||||
await scenarios.run(id);
|
const run = await scenarios.run(id);
|
||||||
|
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.root {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dot ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
position: relative;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pulse ring ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 0.8s ease-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 7px color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Active state ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.active .dot::before {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active .label {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inactive / disabled state ────────────────────────────── */
|
||||||
|
|
||||||
|
.inactive .dot::before {
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive .label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import styles from './AutoRefreshIndicator.module.css';
|
||||||
|
|
||||||
|
export interface AutoRefreshIndicatorProps {
|
||||||
|
active: boolean;
|
||||||
|
pulseKey?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoRefreshIndicator({ active, pulseKey, label = 'Live' }: AutoRefreshIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[styles.root, active ? styles.active : styles.inactive].join(' ')}
|
||||||
|
title={active ? 'Auto-refreshing' : 'Auto-refresh stopped'}
|
||||||
|
aria-label={active ? 'Auto-refreshing' : 'Auto-refresh stopped'}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className={styles.dot}>
|
||||||
|
{pulseKey !== undefined && pulseKey > 0 && (
|
||||||
|
<span key={pulseKey} className={styles.ring} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,12 +19,17 @@
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-secondary-fg);
|
color: #ffffff;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
border-bottom: var(--border-width) solid var(--color-secondary-border);
|
border-bottom: var(--border-width) solid var(--color-secondary-border);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background: var(--color-secondary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .th {
|
||||||
background: var(--color-secondary);
|
background: var(--color-secondary);
|
||||||
|
color: var(--color-secondary-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rounded top corners on first/last header cells */
|
/* Rounded top corners on first/last header cells */
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export { AutoRefreshIndicator } from './AutoRefreshIndicator/AutoRefreshIndicator';
|
||||||
|
export type { AutoRefreshIndicatorProps } from './AutoRefreshIndicator/AutoRefreshIndicator';
|
||||||
|
|
||||||
export { Button } from './Button/Button';
|
export { Button } from './Button/Button';
|
||||||
export type { ButtonProps } from './Button/Button';
|
export type { ButtonProps } from './Button/Button';
|
||||||
|
|
||||||
|
|||||||
Generated
+9
-10
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "liquio-qa-bot",
|
"name": "liqa",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "liquio-qa-bot",
|
"name": "liqa",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"server",
|
"server",
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"name": "liquio-qa-bot-client",
|
"name": "liqa-client",
|
||||||
"version": "0.0.1",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.0.4",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
@@ -10169,12 +10168,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/liquio-qa-bot": {
|
"node_modules/liqa-client": {
|
||||||
"resolved": "server",
|
"resolved": "client",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/liquio-qa-bot-client": {
|
"node_modules/liqa-server": {
|
||||||
"resolved": "client",
|
"resolved": "server",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/load-esm": {
|
"node_modules/load-esm": {
|
||||||
@@ -14400,8 +14399,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"name": "liquio-qa-bot",
|
"name": "liqa-server",
|
||||||
"version": "1.2.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import { AppModule } from "./app.module";
|
|||||||
import { HttpExceptionFilter } from "./filters/http-exception.filter";
|
import { HttpExceptionFilter } from "./filters/http-exception.filter";
|
||||||
import { TraceInterceptor } from "./interceptors/trace.interceptor";
|
import { TraceInterceptor } from "./interceptors/trace.interceptor";
|
||||||
import { LoggingInterceptor } from "./interceptors/logging.interceptor";
|
import { LoggingInterceptor } from "./interceptors/logging.interceptor";
|
||||||
import { name as pkgName, version as pkgVersion } from "../../package.json";
|
import { name as pkgName, version as pkgVersion } from "../package.json";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const logger = new TraceLogger("Bootstrap");
|
const logger = new TraceLogger("Bootstrap");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { BrowserService } from "../browser/browser.service";
|
|||||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||||
import { ScenarioService } from "../scenario/scenario.service";
|
import { ScenarioService } from "../scenario/scenario.service";
|
||||||
|
|
||||||
import pkg from "../../../package.json";
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class McpService {
|
export class McpService {
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ export class ScenarioSchedulerService {
|
|||||||
order: { order: "ASC" },
|
order: { order: "ASC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Guard: if there were no steps (or all steps already resolved via passStepRun),
|
||||||
|
// ensure the run is not left in in_progress.
|
||||||
|
await this.runRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({ status: "pass" })
|
||||||
|
.where("id = :id AND status = 'in_progress'", { id: runId })
|
||||||
|
.execute();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Run #${runId}: unexpected error: ${(err as Error).message}`,
|
`Run #${runId}: unexpected error: ${(err as Error).message}`,
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export class ScenarioController {
|
|||||||
return this.scenarioService.findAll(query);
|
return this.scenarioService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("runs")
|
||||||
|
@ApiOperation({ summary: "List runs across all scenarios (paginated, filterable by status)" })
|
||||||
|
@ApiResponse({ status: 200 })
|
||||||
|
findAllRuns(@Query() query: RunsQueryDto) {
|
||||||
|
return this.scenarioService.findAllRuns(query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@ApiOperation({ summary: "Get a scenario with its steps" })
|
@ApiOperation({ summary: "Get a scenario with its steps" })
|
||||||
@ApiResponse({ status: 200 })
|
@ApiResponse({ status: 200 })
|
||||||
|
|||||||
@@ -140,6 +140,25 @@ export class ScenarioService {
|
|||||||
return { data, total, page, limit };
|
return { data, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllRuns(
|
||||||
|
query: RunsQueryDto,
|
||||||
|
): Promise<PaginatedResult<ScenarioRunEntity & { scenario: ScenarioEntity }>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (query.status) where["status"] = query.status;
|
||||||
|
const [data, total] = await this.runRepo.findAndCount({
|
||||||
|
where,
|
||||||
|
relations: ["stepRuns", "scenario"],
|
||||||
|
order: { id: "DESC", stepRuns: { order: "ASC" } },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return { data, total, page, limit } as PaginatedResult<
|
||||||
|
ScenarioRunEntity & { scenario: ScenarioEntity }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
async findRun(
|
async findRun(
|
||||||
scenarioId: number,
|
scenarioId: number,
|
||||||
runId: number,
|
runId: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user