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 { 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>
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { 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')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user