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