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