From 4e494ce4fdf2faff106aecbc42f0c0d1ad6e8d86 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Fri, 17 Apr 2026 15:13:28 +0300 Subject: [PATCH] feat(scenarios): show last run status and timestamp on scenarios list - extend findAll to join latest run per scenario via correlated subquery - return lastRunStatus and lastRunAt fields in GET /scenarios response - add Last Run column with badge and timestamp to ScenariosPage - add integration tests for null and populated lastRunStatus --- client/src/api/types.ts | 2 ++ client/src/i18n/locales/en.json | 1 + client/src/pages/scenario/ScenariosPage.tsx | 35 ++++++++++++++++++++ server/src/scenario/scenario.service.ts | 36 +++++++++++++++++++-- server/test/scenario.controller.spec.ts | 29 +++++++++++++++++ 5 files changed, 101 insertions(+), 2 deletions(-) diff --git a/client/src/api/types.ts b/client/src/api/types.ts index c7b9bef..880bfee 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -90,6 +90,8 @@ export interface Scenario { environment?: Pick; steps?: ScenarioStep[]; scenarioCredentials?: ScenarioCredential[]; + lastRunStatus?: ScenarioRunStatus | null; + lastRunAt?: string | null; createdAt: string; updatedAt: string; } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 216a06a..b200998 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -127,6 +127,7 @@ "col_id": "ID", "col_name": "Name", "col_description": "Description", + "col_last_run": "Last Run", "col_updated": "Updated", "empty": "No scenarios yet.", "loading": "Loading…", diff --git a/client/src/pages/scenario/ScenariosPage.tsx b/client/src/pages/scenario/ScenariosPage.tsx index fafa190..aba85ed 100644 --- a/client/src/pages/scenario/ScenariosPage.tsx +++ b/client/src/pages/scenario/ScenariosPage.tsx @@ -6,6 +6,7 @@ import { parse as yamlParse } from 'yaml'; import { environments, scenarios } from '../../api'; import type { Environment, Scenario } from '../../api'; import { + Badge, Breadcrumbs, Button, Modal, @@ -17,8 +18,24 @@ import { UuidBadge, useToast, } from '../../ui'; +import type { BadgeVariant } from '../../ui'; +import type { ScenarioRunStatus } from '../../api'; import styles from '../Page.module.css'; +const LAST_RUN_VARIANT: Record = { + pending: 'neutral', + in_progress: 'info', + pass: 'success', + fail: 'error', +}; + +const LAST_RUN_LABEL: Record = { + pending: 'Pending', + in_progress: 'Running', + pass: 'Pass', + fail: 'Fail', +}; + export function ScenariosPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -135,6 +152,24 @@ export function ScenariosPage() { const columns: TableColumn[] = [ { key: 'id', header: t('scenarios.col_id'), render: (s) => , width: 60 }, { key: 'name', header: t('scenarios.col_name'), render: (s) => s.name, sortable: true }, + { + key: 'lastRunStatus', + header: t('scenarios.col_last_run'), + width: 160, + render: (s) => + s.lastRunStatus ? ( + + + + {LAST_RUN_LABEL[s.lastRunStatus]} + + + {s.lastRunAt && } + + ) : ( + + ), + }, { key: 'updatedAt', header: t('scenarios.col_updated'), diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index 4896db7..b33b330 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -63,7 +63,7 @@ export class ScenarioService { async findAll( query: PaginationQueryDto, - ): Promise> { + ): Promise> { const page = query.page ?? 1; const limit = query.limit ?? 20; const orderBy = query.orderBy ?? "id"; @@ -73,7 +73,39 @@ export class ScenarioService { skip: (page - 1) * limit, take: limit, }); - return { data, total, page, limit }; + + let lastRunByScenario = new Map(); + if (data.length > 0) { + const ids = data.map((s) => s.id); + const latestRuns = await this.runRepo + .createQueryBuilder("r") + .select(["r.scenarioId", "r.status", "r.createdAt"]) + .where("r.scenarioId IN (:...ids)", { ids }) + .andWhere((qb) => { + const sub = qb + .subQuery() + .select("MAX(r2.createdAt)") + .from(ScenarioRunEntity, "r2") + .where("r2.scenarioId = r.scenarioId") + .getQuery(); + return `r.createdAt = (${sub})`; + }) + .getMany(); + lastRunByScenario = new Map( + latestRuns.map((r) => [r.scenarioId, { status: r.status, createdAt: r.createdAt }]), + ); + } + + return { + data: data.map((s) => ({ + ...s, + lastRunStatus: lastRunByScenario.get(s.id)?.status ?? null, + lastRunAt: lastRunByScenario.get(s.id)?.createdAt?.toISOString() ?? null, + })), + total, + page, + limit, + }; } async findOne(id: string): Promise { diff --git a/server/test/scenario.controller.spec.ts b/server/test/scenario.controller.spec.ts index d02bc7c..e66b041 100644 --- a/server/test/scenario.controller.spec.ts +++ b/server/test/scenario.controller.spec.ts @@ -171,6 +171,35 @@ describe("ScenarioController", () => { .get("/scenarios?orderDir=SIDEWAYS") .expect(400); }); + + it("returns lastRunStatus and lastRunAt as null when scenario has no runs", async () => { + await createScenario("no-runs-sc"); + const res = await request(app.getHttpServer()) + .get("/scenarios?orderBy=name&orderDir=ASC") + .expect(200); + const item = res.body.data.find( + (s: { name: string }) => s.name === "no-runs-sc", + ); + expect(item).toBeDefined(); + expect(item.lastRunStatus).toBeNull(); + expect(item.lastRunAt).toBeNull(); + }); + + it("returns lastRunStatus and lastRunAt reflecting the most recent run", async () => { + const sc = await createScenario("last-run-status-sc"); + await createRun(sc.id); + const res = await request(app.getHttpServer()) + .get("/scenarios?orderBy=name&orderDir=ASC") + .expect(200); + const item = res.body.data.find( + (s: { name: string }) => s.name === "last-run-status-sc", + ); + expect(item).toBeDefined(); + expect(["pending", "in_progress", "pass", "fail"]).toContain( + item.lastRunStatus, + ); + expect(typeof item.lastRunAt).toBe("string"); + }); }); // ── GET /scenarios/:id ─────────────────────────────────────────────────────