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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user