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:
2026-04-09 21:58:27 +03:00
parent ebcb8b8ff4
commit 1f3a604940
20 changed files with 745 additions and 22 deletions
+8 -1
View File
@@ -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() {
<Route path="/scenarios/:id/edit" element={<EditScenarioPage />} />
<Route path="/scenarios/:id/steps/new" element={<CreateStepPage />} />
<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="/runs" element={<AllRunsPage />} />
<Route path="*" element={<Navigate to="/scenarios" replace />} />
</Routes>
</main>
+21 -3
View File
@@ -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<ScenarioRun> {
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}`);
},
waitForRun(scenarioId: number, runId: number): Promise<ScenarioRun> {
waitForRun(scenarioId: number, runId: number): Promise<ScenarioRunDetail> {
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}`);
},
};
// ── 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 ────────────────────────────────────────────────────────────
export interface CreateStepPayload {
+34 -1
View File
@@ -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<Scenario, 'id' | 'name'>;
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[];
}
+29 -1
View File
@@ -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"
}
}
+18
View File
@@ -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;
}
+118
View File
@@ -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>
);
}
+236
View File
@@ -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>
);
}
+124
View File
@@ -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 { 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() {
<Play size={14} />
{t('scenarios.action_run')}
</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`)}>
<Pencil size={14} />
{t('scenarios.action_edit')}
+2 -1
View File
@@ -27,7 +27,8 @@ export function ScenariosPage() {
}, [load]);
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) => {
@@ -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>
);
}
+6 -1
View File
@@ -19,12 +19,17 @@
padding: var(--space-3) var(--space-4);
font-weight: 700;
font-size: var(--font-size-xs);
color: var(--color-secondary-fg);
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: var(--border-width) solid var(--color-secondary-border);
white-space: nowrap;
background: var(--color-secondary-fg);
}
[data-theme="dark"] .th {
background: var(--color-secondary);
color: var(--color-secondary-fg);
}
/* Rounded top corners on first/last header cells */
+3
View File
@@ -1,3 +1,6 @@
export { AutoRefreshIndicator } from './AutoRefreshIndicator/AutoRefreshIndicator';
export type { AutoRefreshIndicatorProps } from './AutoRefreshIndicator/AutoRefreshIndicator';
export { Button } from './Button/Button';
export type { ButtonProps } from './Button/Button';
+9 -10
View File
@@ -1,11 +1,11 @@
{
"name": "liquio-qa-bot",
"name": "liqa",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liquio-qa-bot",
"name": "liqa",
"version": "1.3.0",
"workspaces": [
"server",
@@ -13,8 +13,7 @@
]
},
"client": {
"name": "liquio-qa-bot-client",
"version": "0.0.1",
"name": "liqa-client",
"dependencies": {
"i18next": "^26.0.4",
"lucide-react": "^1.7.0",
@@ -10169,12 +10168,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/liquio-qa-bot": {
"resolved": "server",
"node_modules/liqa-client": {
"resolved": "client",
"link": true
},
"node_modules/liquio-qa-bot-client": {
"resolved": "client",
"node_modules/liqa-server": {
"resolved": "server",
"link": true
},
"node_modules/load-esm": {
@@ -14400,8 +14399,8 @@
}
},
"server": {
"name": "liquio-qa-bot",
"version": "1.2.0",
"name": "liqa-server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
+1 -1
View File
@@ -8,7 +8,7 @@ import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./filters/http-exception.filter";
import { TraceInterceptor } from "./interceptors/trace.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() {
const logger = new TraceLogger("Bootstrap");
+1 -1
View File
@@ -12,7 +12,7 @@ import { BrowserService } from "../browser/browser.service";
import { CodeExecutorService } from "../code-executor/code-executor.service";
import { ScenarioService } from "../scenario/scenario.service";
import pkg from "../../../package.json";
import pkg from "../../package.json";
@Injectable()
export class McpService {
@@ -114,6 +114,14 @@ export class ScenarioSchedulerService {
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) {
this.logger.error(
`Run #${runId}: unexpected error: ${(err as Error).message}`,
@@ -49,6 +49,13 @@ export class ScenarioController {
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")
@ApiOperation({ summary: "Get a scenario with its steps" })
@ApiResponse({ status: 200 })
+19
View File
@@ -140,6 +140,25 @@ export class ScenarioService {
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(
scenarioId: number,
runId: number,