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
+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>
);
}