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
This commit is contained in:
2026-04-10 15:09:05 +03:00
parent fd655c3253
commit 2046e64428
9 changed files with 119 additions and 9 deletions
+3 -2
View File
@@ -209,8 +209,9 @@ export const runs = {
list(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
},
get(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
return request(`/scenarios/${scenarioId}/run/${runId}`);
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
return request(`/scenarios/${scenarioId}/run/${runId}${qs}`);
},
};
+2
View File
@@ -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",
+8 -1
View File
@@ -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);
}
+38 -2
View File
@@ -19,6 +19,8 @@ import {
Card,
DescriptionList,
Notification,
Pagination,
Search,
Table,
Timestamp,
UuidBadge,
@@ -67,6 +69,11 @@ export function RunDetailPage() {
const [error, setError] = useState<string | null>(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<ReturnType<typeof setInterval> | 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 && (
<div className={styles.stepsSection}>
<div className={styles.sectionHeadingRow}>
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
<Search
value={logSearch}
onChange={setLogSearch}
placeholder={t('runs.logs_search_placeholder')}
/>
</div>
<Table
columns={logColumns}
data={run.logs}
data={run.logs.slice((logPage - 1) * LOG_PAGE_SIZE, logPage * LOG_PAGE_SIZE)}
rowKey={(l) => l.id}
loading={false}
emptyMessage=""
/>
{run.logs.length > LOG_PAGE_SIZE && (
<Pagination
page={logPage}
pageSize={LOG_PAGE_SIZE}
total={run.logs.length}
onPageChange={setLogPage}
/>
)}
</div>
)}
</>
+36
View File
@@ -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);
}
+23
View File
@@ -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 (
<div className={styles.root}>
<SearchIcon size={13} className={styles.icon} />
<input
className={styles.input}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
</div>
);
}
+3
View File
@@ -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';
+2 -1
View File
@@ -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")
+3 -2
View File
@@ -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<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
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 });