diff --git a/osprey_ui/src/components/event_stream/EventStream.tsx b/osprey_ui/src/components/event_stream/EventStream.tsx index 33eae01..c2575f1 100644 --- a/osprey_ui/src/components/event_stream/EventStream.tsx +++ b/osprey_ui/src/components/event_stream/EventStream.tsx @@ -7,7 +7,6 @@ import { } from '@ant-design/icons'; import { Empty, List, Skeleton, Tooltip, Spin } from 'antd'; import classNames from 'classnames'; -import { memoize } from 'lodash'; import { AutoSizer, List as VList, ListRowProps } from 'react-virtualized'; import shallow from 'zustand/shallow'; @@ -20,6 +19,7 @@ import EventStreamIcon from '../../uikit/icons/EventStreamIcon'; import Panel from '../common/Panel'; import EventStreamCard from './EventStreamCard'; import FeatureSelectModal from './FeatureSelectModal'; +import { getSummaryFeaturesForEvent } from './getSummaryFeaturesForEvent'; import styles from './EventStream.module.css'; import { FeatureLocation } from '../../types/ConfigTypes'; @@ -119,14 +119,6 @@ const EventStream: React.FC = () => { return eventStream.length - index < 5; }; - const getSummaryFeatures = React.useMemo( - () => - memoize((actionName: string) => - defaultSummaryFeatures.filter((f) => f.appliesTo(actionName)).map((f) => f.features) - ), - [defaultSummaryFeatures] - ); - const renderListRows = ({ key, index, style, isVisible }: ListRowProps) => { if (isVisible && shouldLoadMoreResults(index)) { handlePaginatedScanQuery(); @@ -134,7 +126,9 @@ const EventStream: React.FC = () => { const item = eventStream[index]; const features = - customSummaryFeatures == null ? getSummaryFeatures(item.extracted_features.ActionName) : [customSummaryFeatures]; + customSummaryFeatures == null + ? getSummaryFeaturesForEvent(item, defaultSummaryFeatures) + : [customSummaryFeatures]; return ( { const features = customSummaryFeatures == null - ? getSummaryFeatures(eventStream[index].extracted_features.ActionName) + ? getSummaryFeaturesForEvent(eventStream[index], defaultSummaryFeatures) : [customSummaryFeatures]; if (!features.length) { diff --git a/osprey_ui/src/components/event_stream/getSummaryFeaturesForEvent.test.ts b/osprey_ui/src/components/event_stream/getSummaryFeaturesForEvent.test.ts new file mode 100644 index 0000000..76f2933 --- /dev/null +++ b/osprey_ui/src/components/event_stream/getSummaryFeaturesForEvent.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from '@rstest/core'; + +import DefaultFeature from '../../models/DefaultFeature'; +import { OspreyEvent } from '../../types/QueryTypes'; + +import { getSummaryFeaturesForEvent } from './getSummaryFeaturesForEvent'; + +const makeEvent = (extracted: Record): OspreyEvent => { + return { + timestamp: '2025-01-01T00:00:00Z', + id: 'evt-1', + extracted_features: extracted, + }; +}; + +describe('getSummaryFeaturesForEvent', () => { + it('returns configured defaults when an action-name pattern matches', () => { + const defaults = [ + new DefaultFeature(['LoginAction'], ['UserId', 'IpAddress']), + new DefaultFeature(['NotApplicable'], ['ShouldNotAppear']), + ]; + const event = makeEvent({ ActionName: 'LoginAction', UserId: '123', IpAddress: '10.0.0.1' }); + + const result = getSummaryFeaturesForEvent(event, defaults); + + expect(result).toEqual([['UserId', 'IpAddress']]); + }); + + it('returns all matching default blocks when multiple apply', () => { + const defaults = [ + new DefaultFeature(['*'], ['UserId']), + new DefaultFeature(['SendMessage'], ['ChannelId', 'GuildId']), + ]; + const event = makeEvent({ ActionName: 'SendMessage', UserId: '1', ChannelId: 'c', GuildId: 'g' }); + + const result = getSummaryFeaturesForEvent(event, defaults); + + expect(result).toEqual([['UserId'], ['ChannelId', 'GuildId']]); + }); + + it('falls back to every extracted feature key (excluding ActionName) when no defaults are configured', () => { + const event = makeEvent({ ActionName: 'AnythingAction', UserId: '42', ChannelId: 'c1', GuildId: 'g1' }); + + const result = getSummaryFeaturesForEvent(event, []); + + expect(result).toHaveLength(1); + expect(new Set(result[0])).toEqual(new Set(['UserId', 'ChannelId', 'GuildId'])); + expect(result[0]).not.toContain('ActionName'); + }); + + it('falls back to event keys when defaults exist but none match the action', () => { + const defaults = [new DefaultFeature(['SomeOtherAction'], ['IrrelevantField'])]; + const event = makeEvent({ ActionName: 'UnmatchedAction', UserId: 'u', GuildId: 'g' }); + + const result = getSummaryFeaturesForEvent(event, defaults); + + expect(result).toHaveLength(1); + expect(new Set(result[0])).toEqual(new Set(['UserId', 'GuildId'])); + }); + + it('returns an empty list when the event has only ActionName and no defaults apply', () => { + const event = makeEvent({ ActionName: 'NakedAction' }); + + const result = getSummaryFeaturesForEvent(event, []); + + expect(result).toEqual([]); + }); + + it('returns an empty list when the event has no extracted features at all', () => { + const event = makeEvent({}); + + const result = getSummaryFeaturesForEvent(event, []); + + expect(result).toEqual([]); + }); +}); diff --git a/osprey_ui/src/components/event_stream/getSummaryFeaturesForEvent.ts b/osprey_ui/src/components/event_stream/getSummaryFeaturesForEvent.ts new file mode 100644 index 0000000..1301a9e --- /dev/null +++ b/osprey_ui/src/components/event_stream/getSummaryFeaturesForEvent.ts @@ -0,0 +1,36 @@ +import DefaultFeature from '../../models/DefaultFeature'; +import { OspreyEvent } from '../../types/QueryTypes'; + +/** + * Resolves which feature blocks to render in an EventStreamCard when the + * user has not chosen a custom selection. + * + * Order of precedence: + * 1. Any DefaultFeature whose action-name pattern matches the event's + * ActionName. + * 2. Otherwise, a single block containing every key on the event + * except ActionName (already rendered as the card title). + */ +export const getSummaryFeaturesForEvent = ( + event: OspreyEvent, + defaultSummaryFeatures: DefaultFeature[] +): Array => { + const actionName = event.extracted_features.ActionName; + const matched = defaultSummaryFeatures.filter((f) => { + return f.appliesTo(actionName); + }); + + if (matched.length > 0) { + return matched.map((f) => { + return f.features; + }); + } + + const fallback = Object.keys(event.extracted_features).filter((key) => { + return key !== 'ActionName'; + }); + + if (fallback.length === 0) return []; + + return [fallback]; +};