From 2046e644287e6a211d3eee7cbf5e9e7eb9200858 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Fri, 10 Apr 2026 15:09:05 +0300 Subject: [PATCH] feat(runs): add log search with backend LIKE filter and pagination - add Search pill component with lucide icon and focus highlight - wire debounced search input to GET /run/:id?q= backend filter - backend filters run logs with LIKE %q% via TypeORM - add sectionHeadingRow layout for heading + search alignment - add i18n keys: runs.step_title, logs_search_placeholder --- client/src/api/client.ts | 5 +-- client/src/i18n/locales/en.json | 2 ++ client/src/pages/Page.module.css | 9 ++++- client/src/pages/run/RunDetailPage.tsx | 42 ++++++++++++++++++++-- client/src/ui/Search/Search.module.css | 36 +++++++++++++++++++ client/src/ui/Search/Search.tsx | 23 ++++++++++++ client/src/ui/index.ts | 3 ++ server/src/scenario/scenario.controller.ts | 3 +- server/src/scenario/scenario.service.ts | 5 +-- 9 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 client/src/ui/Search/Search.module.css create mode 100644 client/src/ui/Search/Search.tsx diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 2508a47..07fa183 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -209,8 +209,9 @@ export const runs = { list(scenarioId: string, page = 1, limit = 20): Promise> { return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); }, - get(scenarioId: string, runId: string): Promise { - return request(`/scenarios/${scenarioId}/run/${runId}`); + get(scenarioId: string, runId: string, q?: string): Promise { + const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''; + return request(`/scenarios/${scenarioId}/run/${runId}${qs}`); }, }; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index e766446..35899b1 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -198,7 +198,9 @@ "field_created": "Started", "field_updated": "Updated", "steps_heading": "Step runs", + "step_title": "Title", "logs_heading": "Logs", + "logs_search_placeholder": "Filter logs…", "step_order": "#", "step_type": "Type", "step_session": "Session", diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index 742003c..d495478 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -6,7 +6,7 @@ } .sectionHeading { - margin: 0; + margin: 0 0 var(--space-3); line-height: 1; font-size: var(--font-size-md); font-weight: 600; @@ -17,6 +17,13 @@ margin-top: var(--space-6); } +.sectionHeadingRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); +} + .breadcrumbs { margin-bottom: var(--space-4); } diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx index 487b50e..426ca10 100644 --- a/client/src/pages/run/RunDetailPage.tsx +++ b/client/src/pages/run/RunDetailPage.tsx @@ -19,6 +19,8 @@ import { Card, DescriptionList, Notification, + Pagination, + Search, Table, Timestamp, UuidBadge, @@ -67,6 +69,11 @@ export function RunDetailPage() { const [error, setError] = useState(null); const [polling, setPolling] = useState(false); const [pulseKey, setPulseKey] = useState(0); + const [logPage, setLogPage] = useState(1); + const [logSearch, setLogSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const logSearchRef = useRef(''); + const LOG_PAGE_SIZE = 25; const pollRef = useRef | null>(null); const FINAL: ScenarioRunStatus[] = ['pass', 'fail']; @@ -79,6 +86,20 @@ export function RunDetailPage() { } }; + useEffect(() => { + const t = setTimeout(() => { + setDebouncedSearch(logSearch); + logSearchRef.current = logSearch; + setLogPage(1); + }, 300); + return () => clearTimeout(t); + }, [logSearch]); + + useEffect(() => { + if (!id || !runId || polling) return; + runs.get(id, runId, debouncedSearch).then(setRun).catch(() => undefined); + }, [debouncedSearch]); + useEffect(() => { if (!id || !runId) return; let cancelled = false; @@ -92,7 +113,7 @@ export function RunDetailPage() { setPolling(true); pollRef.current = setInterval(async () => { try { - const updated = await runs.get(id, runId); + const updated = await runs.get(id, runId, logSearchRef.current); if (cancelled) return; setRun(updated); setPulseKey((k) => k + 1); @@ -215,14 +236,29 @@ export function RunDetailPage() { {run.logs.length > 0 && (
-

{t('runs.logs_heading')}

+
+

{t('runs.logs_heading')}

+ +
l.id} loading={false} emptyMessage="" /> + {run.logs.length > LOG_PAGE_SIZE && ( + + )} )} diff --git a/client/src/ui/Search/Search.module.css b/client/src/ui/Search/Search.module.css new file mode 100644 index 0000000..5651614 --- /dev/null +++ b/client/src/ui/Search/Search.module.css @@ -0,0 +1,36 @@ +.root { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 5px var(--space-3); + border: var(--border-width) solid var(--color-border); + border-radius: var(--radius-full); + background: var(--color-bg-subtle); + transition: border-color var(--transition), background var(--transition); +} + +.root:focus-within { + border-color: var(--color-primary); + background: var(--color-bg); +} + +.icon { + color: var(--color-text-muted); + flex-shrink: 0; + display: block; +} + +.input { + border: none; + background: transparent; + color: var(--color-text); + font-family: var(--font-family); + font-size: var(--font-size-xs); + outline: none; + width: 180px; + min-width: 0; +} + +.input::placeholder { + color: var(--color-text-muted); +} diff --git a/client/src/ui/Search/Search.tsx b/client/src/ui/Search/Search.tsx new file mode 100644 index 0000000..907d855 --- /dev/null +++ b/client/src/ui/Search/Search.tsx @@ -0,0 +1,23 @@ +import { Search as SearchIcon } from 'lucide-react'; +import styles from './Search.module.css'; + +export interface SearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export function Search({ value, onChange, placeholder = 'Search…' }: SearchProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + /> +
+ ); +} diff --git a/client/src/ui/index.ts b/client/src/ui/index.ts index a4fdba1..b700fa6 100644 --- a/client/src/ui/index.ts +++ b/client/src/ui/index.ts @@ -33,6 +33,9 @@ export type { TimestampProps } from './Timestamp/Timestamp'; export { Pagination } from './Pagination/Pagination'; export type { PaginationProps } from './Pagination/Pagination'; +export { Search } from './Search/Search'; +export type { SearchProps } from './Search/Search'; + export { ContextMenu } from './ContextMenu/ContextMenu'; export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu'; diff --git a/server/src/scenario/scenario.controller.ts b/server/src/scenario/scenario.controller.ts index e870b6c..29e96b0 100644 --- a/server/src/scenario/scenario.controller.ts +++ b/server/src/scenario/scenario.controller.ts @@ -205,8 +205,9 @@ export class ScenarioController { findRun( @Param("id", ParseUUIDPipe) id: string, @Param("runId", ParseUUIDPipe) runId: string, + @Query("q") q?: string, ) { - return this.scenarioService.findRun(id, runId); + return this.scenarioService.findRun(id, runId, q); } @Post(":id/run/:runId/wait") diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index b8eb9c5..09e2319 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -1,6 +1,6 @@ import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Like, Repository } from "typeorm"; import { ScenarioEntity } from "./scenario.entity"; import { ScenarioStepEntity } from "./scenario-step.entity"; import { ScenarioRunEntity } from "./scenario-run.entity"; @@ -247,6 +247,7 @@ export class ScenarioService { async findRun( scenarioId: string, runId: string, + q?: string, ): Promise { await this.findOne(scenarioId); // 404 guard const run = await this.runRepo.findOne({ @@ -259,7 +260,7 @@ export class ScenarioService { `Run ${runId} not found in scenario ${scenarioId}`, ); const logs = await this.runLogRepo.find({ - where: { runId }, + where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId }, order: { createdAt: "ASC" }, }); return Object.assign(run, { logs });