@cccsaurora/howler-ui 2.19.0-cases.1122 → 2.19.0-cases.1131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMonaco } from '@monaco-editor/react';
3
- import { Search } from '@mui/icons-material';
4
- import { Box, Button, Card, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, FormGroup, IconButton, Stack, TextField, Tooltip, Typography } from '@mui/material';
3
+ import { CheckCircleOutline, FilterList, RadioButtonUnchecked, Search } from '@mui/icons-material';
4
+ import { Autocomplete, Box, Button, Card, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, TextField, Tooltip, Typography } from '@mui/material';
5
5
  import api from '@cccsaurora/howler-ui/api';
6
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
6
7
  import QueryResultText from '@cccsaurora/howler-ui/components/elements/display/QueryResultText';
7
8
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
8
9
  import QueryEditor from '@cccsaurora/howler-ui/components/routes/advanced/QueryEditor';
9
- import dayjs, {} from 'dayjs';
10
10
  import { useCallback, useEffect, useState } from 'react';
11
11
  import { useTranslation } from 'react-i18next';
12
- import { LocalizationProvider } from '@mui/x-date-pickers';
13
- import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
14
- import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
15
12
  const DEFAULT_TIMEFRAME_DAYS = 14;
16
13
  const PROMPT_CONTEXT = 'isRecordQuery && !suggestWidgetVisible && !renameInputVisible && !inSnippetMode && !quickFixWidgetVisible';
17
14
  const OPTIONS = {
@@ -26,6 +23,15 @@ const OPTIONS = {
26
23
  renderLineHighlight: 'none',
27
24
  overviewRulerLanes: 0
28
25
  };
26
+ const INDEX_OPTIONS = ['hit', 'event'];
27
+ const Subtitle = ({ i18nKey }) => {
28
+ const { t } = useTranslation();
29
+ return (_jsx(Typography, { sx: theme => ({
30
+ color: theme.palette.text.secondary,
31
+ fontSize: '0.9em',
32
+ fontStyle: 'italic'
33
+ }), variant: "body2", children: t(i18nKey) }));
34
+ };
29
35
  const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
30
36
  const { t } = useTranslation();
31
37
  const { dispatchApi } = useMyApi();
@@ -33,8 +39,9 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
33
39
  const [query, setQuery] = useState('');
34
40
  const [destination, setDestination] = useState('');
35
41
  const [indexes, setIndexes] = useState(['hit']);
36
- const [timeframe, setTimeframe] = useState(dayjs().add(DEFAULT_TIMEFRAME_DAYS, 'day'));
37
- const [noExpiry, setNoExpiry] = useState(false);
42
+ const [timeframeDays, setTimeframeDays] = useState(DEFAULT_TIMEFRAME_DAYS);
43
+ const [hasExpiry, setHasExpiry] = useState(true);
44
+ const [expireAfterResolved, setExpireAfterResolved] = useState(false);
38
45
  const [loading, setLoading] = useState(false);
39
46
  const [searching, setSearching] = useState(false);
40
47
  const [response, setResponse] = useState(null);
@@ -42,8 +49,9 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
42
49
  setQuery('');
43
50
  setDestination('');
44
51
  setIndexes(['hit']);
45
- setTimeframe(dayjs().add(DEFAULT_TIMEFRAME_DAYS, 'day'));
46
- setNoExpiry(false);
52
+ setTimeframeDays(DEFAULT_TIMEFRAME_DAYS);
53
+ setHasExpiry(true);
54
+ setExpireAfterResolved(false);
47
55
  setResponse(null);
48
56
  }, []);
49
57
  const handleSearch = useCallback(async () => {
@@ -73,7 +81,8 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
73
81
  await onSubmit({
74
82
  query: query.trim(),
75
83
  destination: destination.trim(),
76
- timeframe: noExpiry ? undefined : timeframe.toISOString(),
84
+ timeframe: hasExpiry ? timeframeDays : null,
85
+ expire_after_resolved: hasExpiry ? expireAfterResolved : false,
77
86
  indexes
78
87
  });
79
88
  onClose();
@@ -81,7 +90,7 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
81
90
  finally {
82
91
  setLoading(false);
83
92
  }
84
- }, [query, destination, noExpiry, timeframe, indexes, onSubmit, onClose]);
93
+ }, [query, destination, hasExpiry, timeframeDays, expireAfterResolved, indexes, onSubmit, onClose]);
85
94
  const onMount = useCallback((ed) => {
86
95
  ed.createContextKey('isRecordQuery', true);
87
96
  }, []);
@@ -116,48 +125,21 @@ const CreateRuleDialog = ({ open, onClose, onSubmit }) => {
116
125
  }, [monaco, handleSearch]);
117
126
  return (_jsxs(Dialog, { open: open, onClose: onClose, maxWidth: "md", fullWidth: true, id: "create-rule-dialog", TransitionProps: { onEnter: handleOpen }, PaperProps: {
118
127
  elevation: 0
119
- }, children: [_jsx(DialogTitle, { children: t('page.cases.rules.create') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 2, sx: { mt: 1 }, children: [_jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('page.cases.rules.query') }), _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Box, { sx: { border: 1, borderColor: 'divider', borderRadius: 1, flex: 1 }, children: _jsx(Card, { variant: "outlined", sx: theme => ({
120
- width: '100%',
121
- height: theme.spacing(5),
122
- p: 1,
123
- position: 'relative',
124
- overflow: 'visible',
125
- display: 'flex',
126
- alignItems: 'center',
127
- justifyContent: 'space-between',
128
- '& .monaco-editor': {
129
- position: 'absolute !important'
130
- },
131
- transition: theme.transitions.create('border-color')
132
- }), onKeyDown: e => e.stopPropagation(), children: _jsx(QueryEditor, { query: query, setQuery: handleQueryChange, language: "lucene", id: "rule-query-editor", height: "20px", editorOptions: OPTIONS, onMount: onMount }) }) }), _jsx(Tooltip, { title: t('route.search'), children: _jsx("span", { children: _jsx(IconButton, { id: "rule-search-button", onClick: handleSearch, disabled: searching || !query.trim(), children: _jsx(Search, {}) }) }) })] }), response ? (_jsx(QueryResultText, { count: response.total, query: query })) : (_jsx(Typography, { sx: theme => ({
133
- color: theme.palette.text.secondary,
134
- fontSize: '0.9em',
135
- fontStyle: 'italic',
136
- mt: 0.5
137
- }), variant: "body2", children: t('hit.search.prompt') }))] }), _jsx(TextField, { id: "rule-destination-input", label: t('page.cases.rules.destination'), value: destination, onChange: e => setDestination(e.target.value), fullWidth: true, placeholder: "alerts/{{howler.analytic}}", helperText: t('page.cases.rules.destination.help'), size: "small" }), _jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('page.cases.rules.indexes') }), _jsxs(FormGroup, { row: true, children: [_jsx(FormControlLabel, { control: _jsx(Checkbox, { id: "rule-index-hit", checked: indexes.includes('hit'), onChange: (_e, checked) => {
138
- setIndexes(prev => {
139
- if (checked) {
140
- return prev.includes('hit') ? prev : [...prev, 'hit'];
141
- }
142
- return prev.filter(i => i !== 'hit');
143
- });
144
- } }), label: t('hit.search.index.hit') }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { id: "rule-index-event", checked: indexes.includes('event'), onChange: (_e, checked) => {
145
- setIndexes(prev => {
146
- if (checked) {
147
- return prev.includes('event') ? prev : [...prev, 'event'];
148
- }
149
- return prev.filter(i => i !== 'event');
150
- });
151
- } }), label: t('hit.search.index.event') })] })] }), _jsxs(Stack, { direction: "row", spacing: 2, alignItems: "center", children: [_jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsx(DateTimePicker, { slotProps: {
152
- textField: {
153
- id: 'rule-timeframe-input',
154
- fullWidth: true,
155
- size: 'small'
156
- }
157
- }, label: t('page.cases.rules.timeframe'), value: timeframe, onChange: nv => {
158
- if (nv) {
159
- setTimeframe(nv);
160
- }
161
- }, disabled: noExpiry, ampm: false, disablePast: true, sx: { flex: 1 } }) }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { id: "rule-no-expiry-checkbox", checked: noExpiry, onChange: (_e, checked) => setNoExpiry(checked) }), label: t('page.cases.rules.no_expiry') })] })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: onClose, children: t('cancel') }), _jsx(Button, { id: "rule-submit-button", variant: "contained", onClick: handleSubmit, disabled: !response || loading || !query.trim() || !destination.trim() || indexes.length === 0, children: t('page.cases.rules.create') })] })] }));
128
+ }, children: [_jsx(DialogTitle, { children: t('page.cases.rules.create') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 3, sx: { mt: 1 }, children: [_jsxs(Box, { children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('page.cases.rules.query') }), _jsx(ChipPopper, { icon: _jsx(FilterList, { fontSize: "small" }), label: indexes.map(opt => t(`hit.search.index.${opt}`)).join(', '), minWidth: "225px", slotProps: { chip: { size: 'small' } }, children: _jsx(Autocomplete, { size: "small", multiple: true, options: INDEX_OPTIONS, value: indexes, onChange: (_ev, values) => values.length > 0 && setIndexes(values), getOptionLabel: opt => t(`hit.search.index.${opt}`), renderInput: params => _jsx(TextField, { ...params }) }) })] }), _jsx(Stack, { direction: "row", alignItems: "center", spacing: 1, children: _jsx(Box, { sx: { border: 1, borderColor: 'divider', borderRadius: 1, flex: 1 }, children: _jsxs(Card, { variant: "outlined", sx: theme => ({
129
+ width: '100%',
130
+ height: theme.spacing(5),
131
+ py: 1,
132
+ pl: 1,
133
+ pr: 0.5,
134
+ position: 'relative',
135
+ overflow: 'visible',
136
+ display: 'flex',
137
+ alignItems: 'center',
138
+ justifyContent: 'space-between',
139
+ '& .monaco-editor': {
140
+ position: 'absolute !important'
141
+ },
142
+ transition: theme.transitions.create('border-color')
143
+ }), onKeyDown: e => e.stopPropagation(), children: [_jsx(QueryEditor, { query: query, setQuery: handleQueryChange, language: "lucene", id: "rule-query-editor", height: "20px", editorOptions: OPTIONS, onMount: onMount }), _jsx(Tooltip, { title: t('route.search'), children: _jsx("span", { children: _jsx(IconButton, { size: "small", id: "rule-search-button", onClick: handleSearch, disabled: searching || !query.trim(), children: _jsx(Search, {}) }) }) })] }) }) }), response ? (_jsx(QueryResultText, { count: response.total, query: query, mb: 0 })) : (_jsx(Subtitle, { i18nKey: "hit.search.prompt" }))] }), _jsxs(Box, { children: [_jsx(TextField, { id: "rule-destination-input", label: t('page.cases.rules.destination'), value: destination, onChange: e => setDestination(e.target.value), fullWidth: true, placeholder: "alerts/{{howler.analytic}}", size: "small" }), _jsx(Subtitle, { i18nKey: "page.cases.rules.destination.help" })] }), _jsxs(Stack, { children: [_jsx(TextField, { id: "rule-timeframe-input", type: "number", label: t('page.cases.rules.timeframe'), value: timeframeDays, onChange: e => setTimeframeDays(Math.max(1, parseInt(e.target.value, 10) || 1)), size: "small", disabled: !hasExpiry, sx: { flex: 1 }, InputProps: { inputProps: { min: 1 } } }), _jsx(Subtitle, { i18nKey: "page.cases.rules.timeframe.help" })] }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", children: [_jsx(Chip, { id: "rule-no-expiry-checkbox", label: t('page.cases.rules.no_expiry'), icon: !hasExpiry ? _jsx(CheckCircleOutline, {}) : _jsx(RadioButtonUnchecked, {}), variant: "filled", color: !hasExpiry ? 'primary' : 'default', clickable: true, disabled: expireAfterResolved, onClick: () => setHasExpiry(prev => !prev) }), _jsx(Chip, { id: "rule-expire-after-resolved", label: t('page.cases.rules.expire_after_resolved'), icon: expireAfterResolved ? _jsx(CheckCircleOutline, {}) : _jsx(RadioButtonUnchecked, {}), variant: "filled", color: expireAfterResolved ? 'primary' : 'default', clickable: hasExpiry, onClick: () => setExpireAfterResolved(prev => !prev), disabled: !hasExpiry })] })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: onClose, children: t('cancel') }), _jsx(Button, { id: "rule-submit-button", variant: "contained", onClick: handleSubmit, disabled: !response || loading || !query.trim() || !destination.trim() || indexes.length === 0, children: t('page.cases.rules.create') })] })] }));
162
144
  };
163
145
  export default CreateRuleDialog;
@@ -35,8 +35,8 @@ vi.mock('@mui/x-date-pickers/AdapterDayjs', () => ({
35
35
  AdapterDayjs: class {
36
36
  }
37
37
  }));
38
- vi.mock('@mui/x-date-pickers/DateTimePicker', () => ({
39
- DateTimePicker: ({ label, disabled }) => (_jsx("input", { id: "rule-timeframe-input", "aria-label": label, disabled: disabled, readOnly: true }))
38
+ vi.mock('components/elements/display/ChipPopper', () => ({
39
+ default: ({ children }) => _jsx(_Fragment, { children: children })
40
40
  }));
41
41
  import api from '@cccsaurora/howler-ui/api';
42
42
  import CreateRuleDialog from './CreateRuleDialog';
@@ -60,8 +60,8 @@ describe('CreateRuleDialog', () => {
60
60
  expect(screen.getByTestId('rule-destination-input')).toBeInTheDocument();
61
61
  expect(screen.getByTestId('rule-timeframe-input')).toBeInTheDocument();
62
62
  expect(screen.getByTestId('rule-no-expiry-checkbox')).toBeInTheDocument();
63
- expect(screen.getByTestId('rule-index-hit')).toBeInTheDocument();
64
- expect(screen.getByTestId('rule-index-event')).toBeInTheDocument();
63
+ expect(screen.getByTestId('rule-expire-after-resolved')).toBeInTheDocument();
64
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
65
65
  });
66
66
  it('disables submit when query is empty', () => {
67
67
  render(_jsx(CreateRuleDialog, { ...defaultProps }));
@@ -149,7 +149,7 @@ describe('CreateRuleDialog', () => {
149
149
  expect(screen.getByText('hit.search.prompt')).toBeInTheDocument();
150
150
  expect(screen.queryByTestId('query-result-text')).not.toBeInTheDocument();
151
151
  });
152
- it('includes timeframe as ISO string when no expiry is unchecked', async () => {
152
+ it('includes timeframe as number when expiry is enabled', async () => {
153
153
  const user = userEvent.setup();
154
154
  const onSubmit = vi.fn().mockResolvedValue(undefined);
155
155
  mockDispatchApi.mockResolvedValueOnce({ items: [], total: 1, offset: 0, rows: 0 });
@@ -169,7 +169,7 @@ describe('CreateRuleDialog', () => {
169
169
  });
170
170
  await waitFor(() => {
171
171
  expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
172
- timeframe: expect.any(String)
172
+ timeframe: 14
173
173
  }));
174
174
  });
175
175
  });
@@ -198,25 +198,18 @@ describe('CreateRuleDialog', () => {
198
198
  expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
199
199
  query: 'event.kind:alert',
200
200
  destination: 'alerts/incoming',
201
- timeframe: undefined
201
+ timeframe: null
202
202
  }));
203
203
  });
204
204
  });
205
- it('defaults to hit index checked', () => {
206
- render(_jsx(CreateRuleDialog, { ...defaultProps }));
207
- const hitCheckbox = screen.getByTestId('rule-index-hit');
208
- const eventCheckbox = screen.getByTestId('rule-index-event');
209
- expect(hitCheckbox).toBeChecked();
210
- expect(eventCheckbox).not.toBeChecked();
211
- });
212
- it('includes both indexes when both are selected', async () => {
205
+ it('includes expire_after_resolved when checkbox is checked', async () => {
213
206
  const user = userEvent.setup();
214
207
  const onSubmit = vi.fn().mockResolvedValue(undefined);
215
208
  mockDispatchApi.mockResolvedValueOnce({ items: [], total: 1, offset: 0, rows: 0 });
216
209
  api.search.hit.post.mockReturnValue('search-request');
217
210
  render(_jsx(CreateRuleDialog, { ...defaultProps, onSubmit: onSubmit }));
218
211
  await act(async () => {
219
- await user.click(screen.getByTestId('rule-index-event'));
212
+ await user.click(screen.getByTestId('rule-expire-after-resolved'));
220
213
  });
221
214
  await act(async () => {
222
215
  await user.type(screen.getByTestId('rule-query-editor'), 'event.kind:alert');
@@ -232,15 +225,31 @@ describe('CreateRuleDialog', () => {
232
225
  });
233
226
  await waitFor(() => {
234
227
  expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
235
- indexes: expect.arrayContaining(['hit', 'event'])
228
+ timeframe: 14,
229
+ expire_after_resolved: true
236
230
  }));
237
231
  });
238
232
  });
239
- it('disables submit when no indexes are selected', async () => {
233
+ it('defaults to hit index checked', () => {
234
+ render(_jsx(CreateRuleDialog, { ...defaultProps }));
235
+ // With 'hit' selected by default, its chip appears in the Autocomplete input area
236
+ expect(screen.getAllByText('hit.search.index.hit').length).toBeGreaterThan(0);
237
+ // 'event' is not selected, so its chip should not appear
238
+ expect(screen.queryByText('hit.search.index.event')).not.toBeInTheDocument();
239
+ });
240
+ it('includes both indexes when both are selected', async () => {
240
241
  const user = userEvent.setup();
242
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
241
243
  mockDispatchApi.mockResolvedValueOnce({ items: [], total: 1, offset: 0, rows: 0 });
242
244
  api.search.hit.post.mockReturnValue('search-request');
243
- render(_jsx(CreateRuleDialog, { ...defaultProps }));
245
+ render(_jsx(CreateRuleDialog, { ...defaultProps, onSubmit: onSubmit }));
246
+ // Open the index Autocomplete and select 'event'
247
+ await act(async () => {
248
+ await user.click(screen.getByRole('combobox'));
249
+ });
250
+ await act(async () => {
251
+ await user.click(screen.getByRole('option', { name: 'hit.search.index.event' }));
252
+ });
244
253
  await act(async () => {
245
254
  await user.type(screen.getByTestId('rule-query-editor'), 'event.kind:alert');
246
255
  });
@@ -250,10 +259,26 @@ describe('CreateRuleDialog', () => {
250
259
  await act(async () => {
251
260
  await user.type(screen.getByTestId('rule-destination-input'), 'alerts/incoming');
252
261
  });
253
- // Uncheck the default hit index
254
262
  await act(async () => {
255
- await user.click(screen.getByTestId('rule-index-hit'));
263
+ await user.click(screen.getByTestId('rule-submit-button'));
256
264
  });
257
- expect(screen.getByTestId('rule-submit-button')).toBeDisabled();
265
+ await waitFor(() => {
266
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
267
+ indexes: expect.arrayContaining(['hit', 'event'])
268
+ }));
269
+ });
270
+ });
271
+ it('prevents deselecting the last index', async () => {
272
+ const user = userEvent.setup();
273
+ render(_jsx(CreateRuleDialog, { ...defaultProps }));
274
+ // 'hit' is selected by default; open the dropdown and try to deselect it
275
+ await act(async () => {
276
+ await user.click(screen.getByRole('combobox'));
277
+ });
278
+ await act(async () => {
279
+ await user.click(screen.getByRole('option', { name: 'hit.search.index.hit' }));
280
+ });
281
+ // 'hit' should still be selected because deselecting the last index is prevented
282
+ expect(screen.getAllByText('hit.search.index.hit').length).toBeGreaterThan(0);
258
283
  });
259
284
  });
@@ -1,14 +1,8 @@
1
1
  import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
2
  import type { FC } from 'react';
3
- export type ObservableType = 'hash' | 'hosts' | 'ip' | 'user' | 'ids' | 'id' | 'uri' | 'signature';
4
- export interface ObservableEntry {
5
- type: ObservableType;
6
- value: string;
7
- /** IDs of the hits/events this observable was seen in */
8
- seenIn: string[];
9
- }
3
+ import type { ObservableEntry } from '../types';
10
4
  declare const Observable: FC<{
11
- asset: ObservableEntry;
5
+ observable: ObservableEntry;
12
6
  case: Case;
13
7
  }>;
14
8
  export default Observable;
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Card, CardContent, Chip, Stack, Typography } from '@mui/material';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Link } from 'react-router-dom';
5
- const Observable = ({ asset, case: _case }) => {
5
+ const Observable = ({ observable, case: _case }) => {
6
6
  const { t } = useTranslation();
7
- return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${asset.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: asset.value })] }), asset.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: asset.seenIn.map(id => {
7
+ return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: observable.value })] }), observable.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: observable.seenIn.map(id => {
8
8
  const entry = _case.items.find(item => item.value === id);
9
9
  return (_jsx(Chip, { clickable: true, size: "small", label: entry.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${entry.path}` }, id));
10
10
  }) })] }))] }) }) }));
@@ -4,7 +4,7 @@ import { render, screen } from '@testing-library/react';
4
4
  import { MemoryRouter } from 'react-router-dom';
5
5
  import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
6
6
  import { describe, expect, it } from 'vitest';
7
- import Observable, {} from './Observable';
7
+ import Observable from './Observable';
8
8
  const makeObservable = (overrides = {}) => ({
9
9
  type: 'ip',
10
10
  value: '192.168.1.1',
@@ -16,7 +16,7 @@ describe('Observable', () => {
16
16
  it('renders the correct label for each type', () => {
17
17
  const cases = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
18
18
  for (const type of cases) {
19
- const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ type, value: 'x' }), case: createMockCase() }) }));
19
+ const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ type, value: 'x' }), case: createMockCase() }) }));
20
20
  expect(screen.getByText(`page.cases.observables.type.${type}`)).toBeTruthy();
21
21
  unmount();
22
22
  }
@@ -24,25 +24,25 @@ describe('Observable', () => {
24
24
  });
25
25
  describe('value display', () => {
26
26
  it('renders the asset value', () => {
27
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ value: '10.0.0.1' }), case: createMockCase() }) }));
27
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ value: '10.0.0.1' }), case: createMockCase() }) }));
28
28
  expect(screen.getByText('10.0.0.1')).toBeTruthy();
29
29
  });
30
30
  it('renders long hash values without truncation', () => {
31
31
  const hash = 'a'.repeat(64);
32
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ type: 'hash', value: hash }), case: createMockCase() }) }));
32
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ type: 'hash', value: hash }), case: createMockCase() }) }));
33
33
  expect(screen.getByText(hash)).toBeTruthy();
34
34
  });
35
35
  });
36
36
  describe('seen-in chips', () => {
37
37
  it('renders nothing when seenIn is empty', () => {
38
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: [] }), case: createMockCase() }) }));
38
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: [] }), case: createMockCase() }) }));
39
39
  expect(screen.queryByText('page.cases.observables.seen_in')).toBeNull();
40
40
  });
41
41
  it('renders "Seen in" label when seenIn has entries', () => {
42
42
  const _case = createMockCase({
43
43
  items: [{ path: 'alerts/test-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
44
44
  });
45
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
45
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
46
46
  expect(screen.getByText('page.cases.observables.seen_in')).toBeTruthy();
47
47
  });
48
48
  it('renders a chip labelled with entry.path for each seenIn id', () => {
@@ -53,7 +53,7 @@ describe('Observable', () => {
53
53
  { path: 'alerts/other-analytic (hit-003)', type: 'hit', value: 'hit-003' }
54
54
  ]
55
55
  });
56
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
56
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
57
57
  expect(screen.getByText('alerts/my-analytic (hit-001)')).toBeTruthy();
58
58
  expect(screen.getByText('events/obs-002')).toBeTruthy();
59
59
  expect(screen.getByText('alerts/other-analytic (hit-003)')).toBeTruthy();
@@ -63,7 +63,7 @@ describe('Observable', () => {
63
63
  case_id: 'case-abc',
64
64
  items: [{ path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
65
65
  });
66
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
66
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
67
67
  const link = screen.getByText('alerts/my-analytic (hit-001)').closest('a');
68
68
  expect(link).not.toBeNull();
69
69
  expect(link?.getAttribute('href')).toBe('/cases/case-abc/alerts/my-analytic (hit-001)');
@@ -0,0 +1,7 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import type { ObservableEntry } from '../types';
3
+ declare const _default: import("react").NamedExoticComponent<{
4
+ observables: ObservableEntry[];
5
+ case: Case;
6
+ }>;
7
+ export default _default;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Chip, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Typography } from '@mui/material';
3
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
4
+ import PluginTypography from '@cccsaurora/howler-ui/components/elements/PluginTypography';
5
+ import { memo, useMemo, useState } from 'react';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { Link } from 'react-router-dom';
8
+ import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
9
+ const ROLE_COLORS = {
10
+ threat: 'error',
11
+ target: 'warning',
12
+ indicator: 'info'
13
+ };
14
+ const getSortValue = (observable, column) => {
15
+ switch (column) {
16
+ case 'type':
17
+ return observable.type;
18
+ case 'value':
19
+ return observable.value.toLowerCase();
20
+ case 'role':
21
+ return observable.role ?? '';
22
+ case 'seen_in':
23
+ return observable.seenIn.length;
24
+ case 'escalation':
25
+ return (observable.sources ?? [])
26
+ .map(s => s.escalation)
27
+ .filter(Boolean)
28
+ .join(',');
29
+ default:
30
+ return '';
31
+ }
32
+ };
33
+ const ObservableTable = ({ observables, case: _case }) => {
34
+ const { t } = useTranslation();
35
+ const [sortColumn, setSortColumn] = useState('type');
36
+ const [sortDirection, setSortDirection] = useState('asc');
37
+ const handleSort = (column) => {
38
+ if (sortColumn === column) {
39
+ setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'));
40
+ }
41
+ else {
42
+ setSortColumn(column);
43
+ setSortDirection('asc');
44
+ }
45
+ };
46
+ const sortedObservables = useMemo(() => {
47
+ return [...observables].sort((a, b) => {
48
+ const aVal = getSortValue(a, sortColumn);
49
+ const bVal = getSortValue(b, sortColumn);
50
+ const cmp = typeof aVal === 'number' && typeof bVal === 'number' ? aVal - bVal : String(aVal).localeCompare(String(bVal));
51
+ return sortDirection === 'asc' ? cmp : -cmp;
52
+ });
53
+ }, [observables, sortColumn, sortDirection]);
54
+ return (_jsx(TableContainer, { children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { sortDirection: sortColumn === 'type' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'type', direction: sortColumn === 'type' ? sortDirection : 'asc', onClick: () => handleSort('type'), children: t('page.cases.observables.columns.type') }) }), _jsx(TableCell, { sortDirection: sortColumn === 'value' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'value', direction: sortColumn === 'value' ? sortDirection : 'asc', onClick: () => handleSort('value'), children: t('page.cases.observables.columns.value') }) }), _jsx(TableCell, { sortDirection: sortColumn === 'role' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'role', direction: sortColumn === 'role' ? sortDirection : 'asc', onClick: () => handleSort('role'), children: t('page.cases.observables.columns.role') }) }), _jsx(TableCell, { align: "right", sortDirection: sortColumn === 'seen_in' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'seen_in', direction: sortColumn === 'seen_in' ? sortDirection : 'asc', onClick: () => handleSort('seen_in'), children: t('page.cases.observables.columns.seen_in') }) }), _jsx(TableCell, { children: t('page.cases.observables.columns.sources') }), _jsx(TableCell, { sortDirection: sortColumn === 'escalation' ? sortDirection : false, children: _jsx(TableSortLabel, { active: sortColumn === 'escalation', direction: sortColumn === 'escalation' ? sortDirection : 'asc', onClick: () => handleSort('escalation'), children: t('page.cases.observables.columns.escalation') }) })] }) }), _jsx(TableBody, { children: sortedObservables.map(observable => {
55
+ const sources = observable.sources ?? [];
56
+ const escalations = [...new Set(sources.map(s => s.escalation).filter(Boolean))];
57
+ const linkedSources = sources.filter(source => source.path);
58
+ return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }) }), _jsx(TableCell, { sx: { maxWidth: 300 }, children: _jsx(PluginTypography, { value: observable.value, context: "table", variant: "body2", sx: { fontFamily: 'monospace', wordBreak: 'break-all' } }) }), _jsx(TableCell, { children: observable.role && (_jsx(Chip, { size: "small", label: t(`page.cases.observables.role.${observable.role}`), color: ROLE_COLORS[observable.role], variant: "outlined" })) }), _jsx(TableCell, { align: "right", children: _jsx(Typography, { variant: "body2", children: observable.seenIn.length }) }), _jsx(TableCell, { children: linkedSources.length === 0 ? null : linkedSources.length === 1 ? (_jsx(Chip, { clickable: true, size: "small", label: linkedSources[0].path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${linkedSources[0].path}` })) : (_jsx(ChipPopper, { label: `${linkedSources[0].path} (+${linkedSources.length - 1})`, slotProps: { chip: { size: 'small', variant: 'outlined' } }, children: _jsx(Stack, { gap: 0.5, children: linkedSources.map(source => (_jsx(Chip, { clickable: true, size: "small", label: source.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${source.path}` }, source.id))) }) })) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: escalations.map(esc => (_jsx(Chip, { size: "small", label: esc, color: ESCALATION_COLORS[esc] ?? 'default' }, esc))) }) })] }, `${observable.type}:${observable.value}`));
59
+ }) })] }) }));
60
+ };
61
+ export default memo(ObservableTable);
@@ -0,0 +1,23 @@
1
+ export type ObservableType = 'hash' | 'hosts' | 'ip' | 'user' | 'ids' | 'id' | 'uri' | 'signature';
2
+
3
+ export type ObservableRole = 'threat' | 'target' | 'indicator';
4
+
5
+ export interface ObservableSource {
6
+ id: string;
7
+ type: 'hit' | 'observable' | 'case';
8
+ path?: string;
9
+ escalation?: string;
10
+ }
11
+
12
+ export interface ObservableEntry {
13
+ type: ObservableType;
14
+ value: string;
15
+ /** IDs of the hits/observables this observable was seen in */
16
+ seenIn: string[];
17
+ /** Resolved source metadata for each seenIn item */
18
+ sources?: ObservableSource[];
19
+ /** Classified role of this observable */
20
+ role?: ObservableRole;
21
+ }
22
+
23
+ export type OriginType = 'hit' | 'observable';
@@ -0,0 +1,29 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
3
+ import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
4
+ import type { Related } from '@cccsaurora/howler-ui/models/entities/generated/Related';
5
+ import { type ObservableEntry, type ObservableRole, type ObservableSource, type ObservableType } from './types';
6
+ /** All Related fields that carry asset values */
7
+ export declare const OBSERVABLE_FIELDS: ObservableType[];
8
+ /** Extract (type, value, seenInId) triples from a record's related field */
9
+ export declare const extractObservables: (related: Related | undefined, recordId: string) => {
10
+ type: ObservableType;
11
+ value: string;
12
+ id: string;
13
+ }[];
14
+ /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
15
+ export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
16
+ /**
17
+ * Classify an asset's role based on case-level lists and per-record outline fields.
18
+ *
19
+ * Resolution order:
20
+ * 1. Case-level `threats[]`, `targets[]`, `indicators[]` (authoritative).
21
+ * 2. Per-record `outline.threat` / `outline.target` (exact match).
22
+ * 3. Per-record `outline.indicators[]`, `threat.indicator.ip/.description`.
23
+ * 4. Default: "indicator" — all assets come from `related.*` fields which are IOCs by nature.
24
+ *
25
+ * Comparison is case-insensitive and trimmed.
26
+ */
27
+ export declare const classifyRole: (value: string, _case: Case, records: Partial<Hit | Event>[]) => ObservableRole;
28
+ /** Resolve source metadata for an asset's seenIn IDs */
29
+ export declare const resolveSources: (seenIn: string[], caseItems: Case["items"], escalationMap: Map<string, string>) => ObservableSource[];
@@ -0,0 +1,114 @@
1
+ import {} from './types';
2
+ /** All Related fields that carry asset values */
3
+ export const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
4
+ /** Extract (type, value, seenInId) triples from a record's related field */
5
+ export const extractObservables = (related, recordId) => {
6
+ if (!related) {
7
+ return [];
8
+ }
9
+ const results = [];
10
+ for (const field of OBSERVABLE_FIELDS) {
11
+ const raw = related[field];
12
+ if (!raw) {
13
+ continue;
14
+ }
15
+ const values = Array.isArray(raw) ? raw : [raw];
16
+ for (const value of values) {
17
+ if (value) {
18
+ results.push({ type: field, value: String(value), id: recordId });
19
+ }
20
+ }
21
+ }
22
+ return results;
23
+ };
24
+ /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
25
+ export const buildObservableEntries = (records) => {
26
+ const map = new Map();
27
+ for (const record of records) {
28
+ const related = record.related ?? record.related;
29
+ const recordId = record.howler?.id ?? record.howler?.id;
30
+ if (!recordId) {
31
+ continue;
32
+ }
33
+ for (const { type, value, id } of extractObservables(related, recordId)) {
34
+ const key = `${type}:${value}`;
35
+ if (!map.has(key)) {
36
+ map.set(key, { type, value, seenIn: [] });
37
+ }
38
+ const entry = map.get(key);
39
+ if (!entry.seenIn.includes(id)) {
40
+ entry.seenIn.push(id);
41
+ }
42
+ }
43
+ }
44
+ return Array.from(map.values());
45
+ };
46
+ /**
47
+ * Classify an asset's role based on case-level lists and per-record outline fields.
48
+ *
49
+ * Resolution order:
50
+ * 1. Case-level `threats[]`, `targets[]`, `indicators[]` (authoritative).
51
+ * 2. Per-record `outline.threat` / `outline.target` (exact match).
52
+ * 3. Per-record `outline.indicators[]`, `threat.indicator.ip/.description`.
53
+ * 4. Default: "indicator" — all assets come from `related.*` fields which are IOCs by nature.
54
+ *
55
+ * Comparison is case-insensitive and trimmed.
56
+ */
57
+ export const classifyRole = (value, _case, records) => {
58
+ const normalized = value.trim().toLowerCase();
59
+ // Case-level classification (most authoritative)
60
+ if (_case.threats?.some(t => String(t).trim().toLowerCase() === normalized)) {
61
+ return 'threat';
62
+ }
63
+ if (_case.targets?.some(t => String(t).trim().toLowerCase() === normalized)) {
64
+ return 'target';
65
+ }
66
+ if (_case.indicators?.some(i => String(i).trim().toLowerCase() === normalized)) {
67
+ return 'indicator';
68
+ }
69
+ // Per-record outline checks
70
+ for (const record of records) {
71
+ const howlerOutline = record.howler?.outline;
72
+ if (howlerOutline) {
73
+ if (howlerOutline.threat && howlerOutline.threat.trim().toLowerCase() === normalized) {
74
+ return 'threat';
75
+ }
76
+ if (howlerOutline.target && howlerOutline.target.trim().toLowerCase() === normalized) {
77
+ return 'target';
78
+ }
79
+ if (howlerOutline.indicators?.some(ind => ind.trim().toLowerCase() === normalized)) {
80
+ return 'indicator';
81
+ }
82
+ }
83
+ const indicator = record.threat?.indicator;
84
+ if (indicator) {
85
+ const indicatorIp = indicator.ip?.trim().toLowerCase();
86
+ const indicatorDesc = indicator.description?.trim().toLowerCase();
87
+ if ((indicatorIp && indicatorIp === normalized) || (indicatorDesc && indicatorDesc === normalized)) {
88
+ return 'indicator';
89
+ }
90
+ }
91
+ }
92
+ // Default: assets from related.* are IOCs
93
+ return 'indicator';
94
+ };
95
+ /** Resolve source metadata for an asset's seenIn IDs */
96
+ export const resolveSources = (seenIn, caseItems, escalationMap) => {
97
+ if (!caseItems?.length) {
98
+ return [];
99
+ }
100
+ return seenIn
101
+ .map(id => {
102
+ const item = caseItems.find(i => i.value === id);
103
+ if (!item) {
104
+ return null;
105
+ }
106
+ return {
107
+ id,
108
+ type: item.type,
109
+ path: item.path,
110
+ escalation: escalationMap.get(id)
111
+ };
112
+ })
113
+ .filter(Boolean);
114
+ };
@@ -0,0 +1 @@
1
+ export {};