Skip to content

Fix Alert Triage widget rendering blank on array-shaped alert fields - #48

Merged
maxcold merged 2 commits into
mainfrom
bug/alert-triage-array-field-crash
Jul 8, 2026
Merged

Fix Alert Triage widget rendering blank on array-shaped alert fields#48
maxcold merged 2 commits into
mainfrom
bug/alert-triage-array-field-crash

Conversation

@davethegut

Copy link
Copy Markdown
Collaborator

Summary

Fixes #47.

Some ECS alert fields (host.name, user.name, process.name, source.ip,
destination.ip) come back as arrays instead of scalars for certain
correlated/network-derived alerts, even though SecurityAlert._source types
them as scalars — Elasticsearch does not enforce that shape at the document
level.

The Alert Triage view's group/sort logic (useAlertSort.ts) assumed a scalar
and threw a TypeError on .localeCompare() when it got an array. With no
error boundary anywhere in the view tree, that exception silently unmounted
the whole widget — rendering as a blank/black panel with no error message,
reproduced both against a live customer environment and independently against
a second live environment.

Changes

  • Root fix: normalize array-shaped ECS entity fields to scalars in
    AlertsService.getAlerts() — the single chokepoint that feeds both the
    triage-alerts and poll-alerts tool paths. Added a small toScalar()
    helper in src/shared/field-utils.ts and reused it to simplify an existing
    one-off array/scalar coercion in entityDetailService.ts.
  • Defense-in-depth: guarded the .localeCompare() calls in
    useAlertSort.ts (coerce to String(...)) and tightened the group-skip
    guard to require actual non-empty strings, in case an unnormalized array
    ever reaches this hook via another path.
  • Resilience: added a reusable ErrorBoundary component
    (src/shared/components/ErrorBoundary/) and wrapped the Alert Triage view's
    root render in it, so any future unexpected data shape shows a visible
    fallback instead of silently blanking the widget.

Test plan

  • npm run typecheck passes
  • npm run lint passes
  • npx vitest run — 403/403 passing, including new regression tests:
    • src/shared/field-utils.test.ts
    • src/elastic/service/alertsService.test.ts (reproduces the exact
      array-shaped payload observed in the field)
    • src/views/alert-triage/hooks/useAlertSort.test.ts (reproduces the
      exact crash)
  • npm run build and npm run mcpb:pack succeed
  • Verified end-to-end in Claude Desktop against a live environment: the
    Alert Triage widget, which previously rendered blank on every load, now
    renders correctly both unfiltered and when filtered to the specific host
    whose alerts have array-shaped fields

Some ECS alert fields (host.name, user.name, process.name, source.ip,
destination.ip) come back as arrays instead of scalars for certain
correlated/network-derived alerts, even though SecurityAlert._source
types them as scalars. The Alert Triage view's group/sort logic assumed
a scalar and threw a TypeError on .localeCompare() when it got an
array, and with no error boundary anywhere in the view tree, the
exception silently unmounted the whole widget to a blank panel.

Normalize array-shaped fields to scalars at the single chokepoint that
feeds both the triage-alerts and poll-alerts tool paths
(AlertsService.getAlerts), add defense-in-depth guards in
useAlertSort's sort/group logic, and add a reusable ErrorBoundary
around the view so any future unexpected shape shows a visible
fallback instead of a silent blank screen.

Fixes #47

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davethegut
davethegut requested a review from maxcold July 7, 2026 15:52
maxcold
maxcold previously approved these changes Jul 7, 2026
The search input's onChange only updated local text state — it never
cleared the actual backend filter (paramsRef.current.query) unless the
user pressed Enter, pressed Escape, or clicked the filter chip's "x".
Deleting the text by any other means (select-and-delete, backspacing
without pressing Enter) left the widget filtered on the old value even
though the input box and chip both appeared empty.

Fix handleInputChange to clear the filter immediately once the input
becomes empty, matching the existing Escape/chip-click behavior. Also
add a request-token guard in loadAlertsImpl so a slower, older response
can never overwrite a fresher one — a related latent race that could
cause the same "stuck filtered" symptom intermittently even via the
correct clearing paths.

Verified against a live local environment: confirmed the widget now
reverts to the unfiltered list immediately when the filter text is
cleared directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davethegut

Copy link
Copy Markdown
Collaborator Author

Follow-up fix in this PR: filter stays applied after clearing the search text

Found and fixed a second, unrelated bug in the same Alert Triage view while
testing the array-field crash fix above.

Repro: apply a filter via the search box (types a host name, applies as a
chip). Clicking the chip's "x" correctly clears back to the full list. But
selecting the filter text and deleting it directly (without pressing Enter)
left the widget filtered on the old value, even though the input box and chip
both went empty.

Root cause: the search input's onChange only updated local text state —
it never cleared the actual backend filter (paramsRef.current.query) unless
Enter, Escape, or the chip's "x" were used.

Fix:

  • handleInputChange now clears the filter immediately once the input
    becomes empty, matching the existing Escape/chip-click behavior.
  • Added a request-token guard in loadAlertsImpl so a slower, older response
    can never overwrite a fresher one — a related latent race that could cause
    the same "stuck filtered" symptom intermittently even via the correct
    clearing paths.
  • New regression tests in App.test.tsx (verified they actually fail without
    the fix before confirming they pass with it).

Verified against a live local environment — the widget now reverts to the
unfiltered list immediately when the filter text is cleared directly.

@maxcold
maxcold merged commit 660a6e9 into main Jul 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants