Portal Community

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

ScenarioBehaviour
Level = Error AND Node = node-abcBoth conditions applied — entry must match both
Text search while level filter activeText search narrows already-level-filtered results
Filters change while live streamingfilteredLogs recomputes on next render — no data lost in executionStore
Clear all filtersSet levelFilter='All', nodeIdFilter=null, textSearch='' — full log list visible
resetExecutionStore() calledLogs cleared in executionStore; filter state in panelStore NOT reset (user's filter preference preserved)
Filters never mutate executionStore. The raw 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.
Node names vs. nodeIds in the filter dropdown. The filter dropdown currently shows raw nodeIds. If you want to show node labels, read the matching WorkflowNode.data.label from workflowStore using the nodeId as a key.