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:
@@ -209,8 +209,9 @@ export const runs = {
|
|||||||
list(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
list(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||||
},
|
},
|
||||||
get(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
|
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
|
||||||
return request(`/scenarios/${scenarioId}/run/${runId}`);
|
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
|
||||||
|
return request(`/scenarios/${scenarioId}/run/${runId}${qs}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,9 @@
|
|||||||
"field_created": "Started",
|
"field_created": "Started",
|
||||||
"field_updated": "Updated",
|
"field_updated": "Updated",
|
||||||
"steps_heading": "Step runs",
|
"steps_heading": "Step runs",
|
||||||
|
"step_title": "Title",
|
||||||
"logs_heading": "Logs",
|
"logs_heading": "Logs",
|
||||||
|
"logs_search_placeholder": "Filter logs…",
|
||||||
"step_order": "#",
|
"step_order": "#",
|
||||||
"step_type": "Type",
|
"step_type": "Type",
|
||||||
"step_session": "Session",
|
"step_session": "Session",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeading {
|
.sectionHeading {
|
||||||
margin: 0;
|
margin: 0 0 var(--space-3);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
margin-top: var(--space-6);
|
margin-top: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sectionHeadingRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumbs {
|
.breadcrumbs {
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
DescriptionList,
|
DescriptionList,
|
||||||
Notification,
|
Notification,
|
||||||
|
Pagination,
|
||||||
|
Search,
|
||||||
Table,
|
Table,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
UuidBadge,
|
UuidBadge,
|
||||||
@@ -67,6 +69,11 @@ export function RunDetailPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
const [pulseKey, setPulseKey] = useState(0);
|
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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const FINAL: ScenarioRunStatus[] = ['pass', 'fail'];
|
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(() => {
|
useEffect(() => {
|
||||||
if (!id || !runId) return;
|
if (!id || !runId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -92,7 +113,7 @@ export function RunDetailPage() {
|
|||||||
setPolling(true);
|
setPolling(true);
|
||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const updated = await runs.get(id, runId);
|
const updated = await runs.get(id, runId, logSearchRef.current);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRun(updated);
|
setRun(updated);
|
||||||
setPulseKey((k) => k + 1);
|
setPulseKey((k) => k + 1);
|
||||||
@@ -215,14 +236,29 @@ export function RunDetailPage() {
|
|||||||
|
|
||||||
{run.logs.length > 0 && (
|
{run.logs.length > 0 && (
|
||||||
<div className={styles.stepsSection}>
|
<div className={styles.stepsSection}>
|
||||||
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
|
<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
|
<Table
|
||||||
columns={logColumns}
|
columns={logColumns}
|
||||||
data={run.logs}
|
data={run.logs.slice((logPage - 1) * LOG_PAGE_SIZE, logPage * LOG_PAGE_SIZE)}
|
||||||
rowKey={(l) => l.id}
|
rowKey={(l) => l.id}
|
||||||
loading={false}
|
loading={false}
|
||||||
emptyMessage=""
|
emptyMessage=""
|
||||||
/>
|
/>
|
||||||
|
{run.logs.length > LOG_PAGE_SIZE && (
|
||||||
|
<Pagination
|
||||||
|
page={logPage}
|
||||||
|
pageSize={LOG_PAGE_SIZE}
|
||||||
|
total={run.logs.length}
|
||||||
|
onPageChange={setLogPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ export type { TimestampProps } from './Timestamp/Timestamp';
|
|||||||
export { Pagination } from './Pagination/Pagination';
|
export { Pagination } from './Pagination/Pagination';
|
||||||
export type { PaginationProps } 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 { ContextMenu } from './ContextMenu/ContextMenu';
|
||||||
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
|
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
|
||||||
|
|
||||||
|
|||||||
@@ -205,8 +205,9 @@ export class ScenarioController {
|
|||||||
findRun(
|
findRun(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Param("runId", ParseUUIDPipe) runId: 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")
|
@Post(":id/run/:runId/wait")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Like, Repository } from "typeorm";
|
||||||
import { ScenarioEntity } from "./scenario.entity";
|
import { ScenarioEntity } from "./scenario.entity";
|
||||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||||
import { ScenarioRunEntity } from "./scenario-run.entity";
|
import { ScenarioRunEntity } from "./scenario-run.entity";
|
||||||
@@ -247,6 +247,7 @@ export class ScenarioService {
|
|||||||
async findRun(
|
async findRun(
|
||||||
scenarioId: string,
|
scenarioId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
q?: string,
|
||||||
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
|
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
|
||||||
await this.findOne(scenarioId); // 404 guard
|
await this.findOne(scenarioId); // 404 guard
|
||||||
const run = await this.runRepo.findOne({
|
const run = await this.runRepo.findOne({
|
||||||
@@ -259,7 +260,7 @@ export class ScenarioService {
|
|||||||
`Run ${runId} not found in scenario ${scenarioId}`,
|
`Run ${runId} not found in scenario ${scenarioId}`,
|
||||||
);
|
);
|
||||||
const logs = await this.runLogRepo.find({
|
const logs = await this.runLogRepo.find({
|
||||||
where: { runId },
|
where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId },
|
||||||
order: { createdAt: "ASC" },
|
order: { createdAt: "ASC" },
|
||||||
});
|
});
|
||||||
return Object.assign(run, { logs });
|
return Object.assign(run, { logs });
|
||||||
|
|||||||
Reference in New Issue
Block a user