Client-Side Filtering
The Logs tab provides three in-memory filters: level, nodeId, and text search. All filtering is applied on the client against the logs array in executionStore — no server round-trips are involved. The filtered result is passed directly to VirtualLogList.
Filter State
Filter state lives in flowObserverPanelStore because it is UI state, not data state. The executionStore is never modified when filters change.
// flowObserverPanelStore — filter fields
interface LogFilterState {
levelFilter : LogLevel | 'All'; // 'All' | 'Trace' | 'Debug' | 'Information' | 'Warning' | 'Error'
nodeIdFilter : string | null; // null = show all nodes
textSearch : string; // '' = no text filter (case-insensitive substring)
}
// Initial state — no filters active
const defaultLogFilter: LogFilterState = {
levelFilter : 'All',
nodeIdFilter : null,
textSearch : ''
};
Filter Application
Filtering runs inside a useMemo — it re-executes only when logs or the filter state changes. The memoized result is passed to VirtualLogList.
// ExecutionLogsTabContent.tsx
const logs = useExecutionStore(s => s.logs);
const { levelFilter, nodeIdFilter, textSearch } = useFlowObserverPanelStore(
s => s.logFilter,
shallow
);
const filteredLogs = useMemo(() => {
let result = logs;
// 1. Level filter
if (levelFilter !== 'All') {
result = result.filter(l => l.level === levelFilter);
}
// 2. Node filter
if (nodeIdFilter !== null) {
result = result.filter(l => l.nodeId === nodeIdFilter);
}
// 3. Text search (case-insensitive substring on message)
if (textSearch.trim() !== '') {
const q = textSearch.toLowerCase();
result = result.filter(l => l.message.toLowerCase().includes(q));
}
return result;
}, [logs, levelFilter, nodeIdFilter, textSearch]);
return <VirtualLogList logs={filteredLogs} />;
Level Filter
The level filter is a dropdown showing the five log levels plus an "All" option. The label also shows the count of entries at that level.
// LogLevelFilter.tsx
const LOG_LEVELS: Array<LogLevel | 'All'> = [
'All', 'Error', 'Warning', 'Information', 'Debug', 'Trace'
];
export function LogLevelFilter() {
const levelFilter = useFlowObserverPanelStore(s => s.logFilter.levelFilter);
const setLevelFilter = useFlowObserverPanelStore(s => s.setLevelFilter);
const logs = useExecutionStore(s => s.logs);
const counts = useMemo(() => {
const map: Record<string, number> = { All: logs.length };
logs.forEach(l => { map[l.level] = (map[l.level] ?? 0) + 1; });
return map;
}, [logs]);
return (
<select value={levelFilter} onChange={e => setLevelFilter(e.target.value as LogLevel | 'All')}>
{LOG_LEVELS.map(level => (
<option key={level} value={level}>
{level} ({counts[level] ?? 0})
</option>
))}
</select>
);
}
Node Filter
The node filter is a dropdown populated from the distinct nodeId values present in logs. It is not seeded from the workflow definition — only nodes that have actually emitted logs appear in the list.
// NodeIdFilter.tsx
export function NodeIdFilter() {
const nodeIdFilter = useFlowObserverPanelStore(s => s.logFilter.nodeIdFilter);
const setNodeIdFilter = useFlowObserverPanelStore(s => s.setNodeIdFilter);
const logs = useExecutionStore(s => s.logs);
// Build distinct nodeId list from actual log entries
const nodeIds = useMemo(() => {
return [...new Set(logs.map(l => l.nodeId))];
}, [logs]);
return (
<select
value={nodeIdFilter ?? ''}
onChange={e => setNodeIdFilter(e.target.value || null)}
>
<option value="">All Nodes</option>
{nodeIds.map(id => (
<option key={id} value={id}>{id}</option>
))}
</select>
);
}
Text Search
The text search input performs a case-insensitive substring match on the message field. It does not search structured fields — only the human-readable message string.
// LogSearchInput.tsx — debounced to avoid filtering on every keystroke
export function LogSearchInput() {
const textSearch = useFlowObserverPanelStore(s => s.logFilter.textSearch);
const setTextSearch = useFlowObserverPanelStore(s => s.setTextSearch);
const [localValue, setLocalValue] = useState(textSearch);
// Debounce 200ms — avoids running useMemo on each keystroke
useEffect(() => {
const timer = setTimeout(() => setTextSearch(localValue), 200);
return () => clearTimeout(timer);
}, [localValue]);
return (
<input
type="text"
placeholder="Search logs..."
value={localValue}
onChange={e => setLocalValue(e.target.value)}
/>
);
}
Filter Interactions
| Scenario | Behaviour |
|---|---|
| Level = Error AND Node = node-abc | Both conditions applied — entry must match both |
| Text search while level filter active | Text search narrows already-level-filtered results |
| Filters change while live streaming | filteredLogs recomputes on next render — no data lost in executionStore |
| Clear all filters | Set levelFilter='All', nodeIdFilter=null, textSearch='' — full log list visible |
| resetExecutionStore() called | Logs cleared in executionStore; filter state in panelStore NOT reset (user's filter preference preserved) |
logs array in executionStore always holds every received log entry. Filters are view-only transformations. Switching from "Error only" back to "All" restores the full list without any API call.
WorkflowNode.data.label from workflowStore using the nodeId as a key.