Mirror of https://github.com/roostorg/osprey
github.com/roostorg/osprey
1import * as React from 'react';
2import {
3 UnorderedListOutlined,
4 ProfileOutlined,
5 SortAscendingOutlined,
6 SortDescendingOutlined,
7} from '@ant-design/icons';
8import { Empty, List, Skeleton, Tooltip, Spin } from 'antd';
9import classNames from 'classnames';
10import { memoize } from 'lodash';
11import { AutoSizer, List as VList, ListRowProps } from 'react-virtualized';
12import shallow from 'zustand/shallow';
13
14import { getFeatureLocations, getScanQueryResults } from '../../actions/EventActions';
15import useApplicationConfigStore from '../../stores/ApplicationConfigStore';
16import useQueryStore from '../../stores/QueryStore';
17import { OspreyEvent, ScanQueryOrder } from '../../types/QueryTypes';
18import OspreyButton from '../../uikit/OspreyButton';
19import EventStreamIcon from '../../uikit/icons/EventStreamIcon';
20import Panel from '../common/Panel';
21import EventStreamCard from './EventStreamCard';
22import FeatureSelectModal from './FeatureSelectModal';
23
24import styles from './EventStream.module.css';
25import { FeatureLocation } from '../../types/ConfigTypes';
26
27const QUERY_ROW_LIMIT = 100;
28const HEADER_HEIGHT = 44;
29const ADDITIONAL_PADDING = 4;
30const ROW_HEIGHT = 44;
31const EMPTY_ROW_HEIGHT = 116;
32const NUM_COLUMNS = 3;
33
34const calculateCardHeight = (currentHeight: number, featureBlock: readonly string[], item: OspreyEvent) => {
35 const numFeatures = featureBlock.reduce(
36 (acc, feature) => acc + (Object.prototype.hasOwnProperty.call(item.extracted_features, feature) ? 1 : 0),
37 0
38 );
39 const rows = Math.ceil(numFeatures / NUM_COLUMNS);
40 return currentHeight + rows * ROW_HEIGHT;
41};
42
43const EventStream: React.FC = () => {
44 const [
45 executedQuery,
46 sortOrder,
47 entityFeatureFilters,
48 applyIfQueryIsCurrent,
49 updateSortOrder,
50 customSummaryFeatures,
51 ] = useQueryStore(
52 (state) => [
53 state.executedQuery,
54 state.sortOrder,
55 state.entityFeatureFilters,
56 state.applyIfQueryIsCurrent,
57 state.updateSortOrder,
58 state.customSummaryFeatures,
59 ],
60 shallow
61 );
62 const defaultSummaryFeatures = useApplicationConfigStore((state) => state.defaultSummaryFeatures);
63 const vlistRef = React.useRef<VList>(null);
64
65 const [isLoading, setIsLoading] = React.useState(false);
66 const [eventStream, setEventStream] = React.useState<OspreyEvent[]>([]);
67 const [queryOffset, setQueryOffset] = React.useState<string | null>(null);
68 const [isListView, setIsListView] = React.useState(false);
69 const [featureLocations, setFeatureLocations] = React.useState<FeatureLocation[] | undefined>([]);
70
71 const handleScanQuery = React.useCallback(
72 async (currentEvents: OspreyEvent[] = [], newQueryOffset?: string) => {
73 setIsLoading(true);
74 const { events, offset } = await getScanQueryResults(
75 { ...executedQuery, entityFeatureFilters },
76 sortOrder,
77 QUERY_ROW_LIMIT,
78 newQueryOffset
79 );
80
81 applyIfQueryIsCurrent(executedQuery, () => {
82 setEventStream([...currentEvents, ...events]);
83 setQueryOffset(offset);
84 setIsLoading(false);
85 });
86 },
87 [executedQuery, sortOrder, entityFeatureFilters, applyIfQueryIsCurrent]
88 );
89
90 React.useEffect(() => {
91 const { start, end } = executedQuery;
92 setEventStream([]);
93 setQueryOffset(null);
94
95 if (start !== '' && end !== '') {
96 // This should only be called if the query has changed, so reset local state
97 handleScanQuery();
98 }
99 }, [handleScanQuery, executedQuery]);
100
101 React.useEffect(() => {
102 (async () => {
103 const { locations } = await getFeatureLocations();
104 setFeatureLocations(locations);
105 })();
106 }, []);
107
108 React.useEffect(() => {
109 vlistRef.current?.recomputeRowHeights();
110 }, [customSummaryFeatures, isListView, vlistRef]);
111
112 const handlePaginatedScanQuery = () => {
113 if (eventStream.length === 0 || queryOffset == null || isLoading) return;
114 handleScanQuery(eventStream, queryOffset);
115 };
116
117 const shouldLoadMoreResults = (index: number): boolean => {
118 return eventStream.length - index < 5;
119 };
120
121 const getSummaryFeatures = React.useMemo(
122 () =>
123 memoize((actionName: string) =>
124 defaultSummaryFeatures.filter((f) => f.appliesTo(actionName)).map((f) => f.features)
125 ),
126 [defaultSummaryFeatures]
127 );
128
129 const renderListRows = ({ key, index, style, isVisible }: ListRowProps) => {
130 if (isVisible && shouldLoadMoreResults(index)) {
131 handlePaginatedScanQuery();
132 }
133
134 const item = eventStream[index];
135 const features =
136 customSummaryFeatures == null ? getSummaryFeatures(item.extracted_features.ActionName) : [customSummaryFeatures];
137
138 return (
139 <List.Item
140 className={classNames(styles.listItemWrapper, { [styles.listView]: isListView })}
141 key={key}
142 style={style}
143 >
144 <Skeleton loading={isLoading} active title paragraph={{ rows: 5 }}>
145 <EventStreamCard
146 selectedFeatures={features}
147 featureLocations={featureLocations}
148 eventDetails={item}
149 isListView={isListView}
150 />
151 </Skeleton>
152 </List.Item>
153 );
154 };
155
156 const getRowHeight = ({ index }: { index: number }): number => {
157 if (isListView) return HEADER_HEIGHT;
158
159 const features =
160 customSummaryFeatures == null
161 ? getSummaryFeatures(eventStream[index].extracted_features.ActionName)
162 : [customSummaryFeatures];
163
164 if (!features.length) {
165 return EMPTY_ROW_HEIGHT;
166 }
167
168 return features.reduce(
169 (height: number, featureBlock: readonly string[]) =>
170 calculateCardHeight(height, featureBlock, eventStream[index]),
171 HEADER_HEIGHT + ADDITIONAL_PADDING
172 );
173 };
174
175 const renderVirtualizedList = () => {
176 if (eventStream.length === 0) {
177 return isLoading ? (
178 <Spin size="large" style={{ position: 'absolute', top: 40, left: 0, right: 0 }} />
179 ) : (
180 <Empty style={{ paddingTop: 40 }} />
181 );
182 }
183
184 return (
185 <AutoSizer>
186 {({ width, height }) => (
187 <VList
188 className={styles.virtualizedList}
189 width={width}
190 height={height}
191 rowCount={eventStream.length}
192 rowHeight={getRowHeight}
193 rowRenderer={renderListRows}
194 ref={vlistRef}
195 />
196 )}
197 </AutoSizer>
198 );
199 };
200
201 const handleToggleViewType = () => {
202 setIsListView(!isListView);
203 };
204
205 const handleSetQueryOrder = (order: ScanQueryOrder) => {
206 updateSortOrder(order);
207 };
208
209 const renderTitleRight = () => {
210 const viewSwitchIcon = isListView ? <ProfileOutlined /> : <UnorderedListOutlined />;
211 const oppositeView = isListView ? 'card' : 'list';
212 const oppositeOrder = sortOrder === ScanQueryOrder.ASCENDING ? ScanQueryOrder.DESCENDING : ScanQueryOrder.ASCENDING;
213 const orderIcon = sortOrder === ScanQueryOrder.ASCENDING ? <SortAscendingOutlined /> : <SortDescendingOutlined />;
214
215 return (
216 <div className={styles.buttonContainer}>
217 <Tooltip title={`Switch to sorting by date ${oppositeOrder} (currently: ${sortOrder})`}>
218 <span>
219 <OspreyButton
220 className={styles.viewSwitchButton}
221 icon={orderIcon}
222 onClick={() => handleSetQueryOrder(oppositeOrder)}
223 />
224 </span>
225 </Tooltip>
226 <Tooltip title={`Switch to ${oppositeView} view`}>
227 <span>
228 <OspreyButton className={styles.viewSwitchButton} icon={viewSwitchIcon} onClick={handleToggleViewType} />
229 </span>
230 </Tooltip>
231 <FeatureSelectModal />
232 </div>
233 );
234 };
235
236 return (
237 <Panel
238 className={styles.eventStreamPanel}
239 title="Event Stream"
240 titleRight={renderTitleRight()}
241 icon={<EventStreamIcon />}
242 >
243 <div className={styles.listWrapper}>
244 <div className={styles.eventStreamList}>{renderVirtualizedList()}</div>
245 </div>
246 </Panel>
247 );
248};
249
250export default EventStream;