Filtering Architecture
Design
currentFilters is a flat object mapping column names to values:
let currentFilters = {};
// Example: { "Dest Port": "80", "Source IP": "10.0.0.1" }
Filters are global — they apply across all tabs (Alert, DNS, HTTP, All Events, etc.). When a filter is set on one tab, it persists when switching to another tab.
Search (currentSearch) is a separate server-side full-text filter using SQLite FTS5. It is an array of terms — typing tcp 80 creates two chips [🔍 "tcp" ×] [🔍 "80" ×]. Each chip is independently removable. Quoted phrases like "GET /login" become a single chip. Search terms AND together on the backend. Search complements column filters: you can search for 192.168.1.1 and then further narrow with aggregation filters. Search is cleared when loading a new PCAP or when clicking Clear All.
Scalable Fetch vs. Full-Batch Fallback
With no column filter active, the table, aggregations, and Sankey diagram all use lightweight, unbounded server-side queries - fetchEventsPage() (SQL LIMIT/OFFSET, always exactly CONFIG.TABLE_PAGE_SIZE rows per page), fetchAggregationData(), and fetchSankeyData() (both server-side GROUP BY over the full matching set). None of these are bounded by the query-limit setting described below - they work the same regardless of dataset size.
The moment a column filter is applied (applyFilter/applyFilters, which sets currentFilters[column] = value), everything falls back to full-batch client-side mode: canUseScalableFetch(), canUseServerAggregation(), and canUseServerSankey() all check Object.keys(currentFilters).length === 0 and become false. This is because arbitrary column-value filtering has no server-side WHERE-clause equivalent - only search (q=) and type= are implemented server-side. applyFilters() then calls ensureCappedBatch(eventType), which fetches up to getUserQueryLimit() raw rows (default 75,000, user-adjustable up to the server ceiling MAX_QUERY_LIMIT = 100,000 - see GET /api/limits in api.md) into tabDataCache/allEvents, and filtering, sorting, table pagination, and (for log/sigmaalert/binary, which have no server-side aggregation at all) aggregation all happen in JS over that in-memory array.
log/sigmaalert/binary event types always need the full batch for their aggregation view regardless of filters, since their columns are dynamic/data-dependent or (for binary) have no scalable path at all - see needsFullBatch().
If the true match count exceeds what was fetched, ensureCappedBatch() marks the type in truncatedTypes, and isCurrentTabTruncated() drives a visible warning in the filter bar - filtered/aggregated results are never silently incomplete without an indicator, but they can still be a partial view of a very large, heavily-filtered dataset.
Key Functions
| Function | Role |
|---|---|
performSearch() |
Splits searchInput into terms, pushes to currentSearch array, clears input, refreshes data |
clearSearchTerm(index) |
Removes single term at index from currentSearch, refreshes data |
applyFilter(sectionId, columnName, value) |
Sets currentFilters[columnName] = value, rebuilds table + agg grid + stats + sankey |
clearFilter(columnName) |
Deletes currentFilters[columnName], rebuilds |
clearAllFilters() |
Clears both currentSearch and currentFilters, refreshes data from server |
getFilteredEvents(sectionId, events, eventType) |
Returns events matching all active column filters |
buildSection(eventType, events) |
Builds the main data table, applies currentFilters internally |
buildAggregationsSection(eventType, events) |
Builds agg grid; expects pre-filtered events |
buildAllEvents() |
Builds "All Events" table, applies currentFilters internally |
buildAggregationsSectionAll() |
Builds agg grid for "All Events"; applies currentFilters internally |
updateFilterBarVisibility() |
Shows/hides the filter bar when search or filters exist |
buildStats(filteredStats) |
Rebuilds stats cards with filtered/total counts when constraints are active |
canUseScalableFetch() / canUseServerAggregation(eventType) / canUseServerSankey(eventType) |
true when no column filter is active (and, for scalable fetch, no client-only sort) - gates server-side pagination/aggregation/Sankey vs. the full-batch fallback |
ensureCappedBatch(eventType) |
Fetches up to getUserQueryLimit() raw rows into tabDataCache/allEvents; no-op if already cached |
needsFullBatch(eventType) |
true when the aggregation view or Sankey diagram can't use its server-side fetch (log/sigmaalert always; any type when a column filter is active) |
getUserQueryLimit() |
Reads/validates the user's configured full-batch row cap from localStorage, falling back to CONFIG.DEFAULT_QUERY_LIMIT |
computeFilteredStats() |
Counts events by type from the filtered subset |
getSankeyEvents() |
Returns filtered events for the currently visible tab for the Sankey diagram |
refreshCurrentView() |
Rebuilds the current tab's table, aggregations, stats, filter bar, and Sankey diagram |
updateSankeyDiagram() |
Rebuilds the Sankey diagram using events from getSankeyEvents() |
Data Flow
buildSection/buildAllEventsapplycurrentFiltersinternally- Filter bar is rendered by
updateFilterBarVisibility()into#filterBarContainer buildAggregationsSectionexpects pre-filtered events from the callerloadTabDatamust callgetFilteredEvents()before passing data tobuildAggregationsSectionbuildAggregationsSectionAllappliescurrentFiltersinternally (exception to the pre-filtered rule)
Column Overlap
Per-event-type columns differ from "All Events" columns:
| Shared columns | Per-type only | All-events only |
|---|---|---|
| Time, Protocol, Source IP, Source Port, Dest IP, Dest Port | Everything else getColumnsForType() returns per event type - see below |
Type (event type itself, e.g. "DNS"/"ANOMALY"), Detail |
Per-type columns are not enumerated here, including their count. Every
event type has its own real per-type columns (getColumnsForType() in
static/socrates.js) - e.g. Alert/Category/Severity for alert, Query/Type
for dns, JA3/JA3S for quic, Opcode/Fin/Payload for websocket,
Operation/Message ID/Result Code for ldap. A prior version of this doc
tried to keep a hardcoded copy of that list here (and later, just a
hardcoded event-type/column count as a compromise); both went stale the
same way, every time a new event type was added - even the count needed
updating far more often than anyone remembered to. Treat getColumnsForType()
as the only source of truth for what columns exist per type, including how
many there are - don't re-add a number here either.
Two columns mean something different depending on context, and that
distinction genuinely belongs in this doc (it's the one thing a fresh read
of getColumnsForType() won't tell you by itself):
- Type: on a per-type tab it's protocol-specific (a DNS/mdns record
type, dnp3's own type field, anomaly's type field, ...). In "All
Events" it means the event_type itself, uppercased (e.g. "HTTP").
extractAllValue() special-cases 'Type' for exactly this reason.
- Detail: only exists in "All Events" - a one-line, event-specific
summary (e.g. the DNS query name, the alert signature, the ldap
operation). Each event type's Detail rendering lives in extractValue()'s
shared 'Detail' case.
When filtering, both extractValue() and extractAllValue() handle every
per-type column. extractAllValue() delegates everything except 'Type'
straight to extractValue(), so it automatically supports the full,
current set of per-type columns without needing its own copy of the list -
this is also why a filter set on one tab (e.g., Alert on the Alerts tab)
correctly matches events when switching to "All Events". Only truly
unknown columns return ''.
REGRESSION, fixed: extractAllValue() used to also special-case
'Command' (hardcoded to e.ftp?.command, which went stale the moment
pgsql/enip/pop3 gained their own real command fields - it silently returned
'' for all three in the "All Events" view) and 'Message' (read
e.anomaly?.message, a field that has never existed in Suricata's eve.json
anomaly schema - the real field is event, and no column has been labeled
'Message' since anomaly gained real columns). Both overrides were removed;
extractValue()'s own per-protocol handling was already correct.
Known Gotchas
1. currentFilters must stay flat
Nesting it as {sectionId: {columnName: value}} causes filters to disappear when switching tabs because each tab creates a new empty section entry. Tests enforce this:
- test_currentFilters_is_flat_object_not_nested
- test_all_filtering_functions_use_global_currentFilters
2. buildAggregationsSection expects pre-filtered events
When calling buildAggregationsSection(eventType, events) in aggregation mode, events must already be filtered through getFilteredEvents(). Passing raw events will show incorrect aggregation counts. Tests enforce this:
- test_loadTabData_filters_agg_tables_in_advanced_mode_cached
- test_loadTabData_filters_agg_tables_in_advanced_mode_fresh
3. Aggregation toggle must filter before building agg tables
When the user toggles Aggregation ON, the handler must call getFilteredEvents() before buildAggregationsSection(). See the advancedToggle change listener.
4. onclick quoting
Never use JSON.stringify() inside onclick attributes — it produces double-quoted strings that break inside double-quoted HTML attributes. Use single-quoted template expressions with escaped internal single quotes. Tests enforce this:
- test_no_json_stringify_in_apply_filter_onclick
- test_no_json_stringify_in_clear_filter_onclick
- test_no_bare_json_stringify_in_onclick_templates
Running Tests
python3 -m unittest discover tests -v
All tests must pass. The filtering-related tests are in:
TestFilterOnclickQuoting(6 tests)TestAdvancedModeFilterBar(25 tests)TestSearchUI(30 tests)