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

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.
@@ -2,5 +2,6 @@ import type { FC } from 'react';
2
2
  declare const QueryResultText: FC<{
3
3
  count: number;
4
4
  query: string;
5
+ mb?: number;
5
6
  }>;
6
7
  export default QueryResultText;
@@ -2,13 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Typography } from '@mui/material';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Link } from 'react-router-dom';
5
- const QueryResultText = ({ count, query }) => {
5
+ const QueryResultText = ({ count, query, mb = 0.5 }) => {
6
6
  const { t } = useTranslation();
7
7
  return (_jsxs(Typography, { sx: theme => ({
8
8
  color: theme.palette.text.secondary,
9
9
  fontSize: '0.9em',
10
10
  fontStyle: 'italic',
11
- mb: 0.5,
11
+ mb,
12
12
  '& a': { color: theme.palette.text.secondary }
13
13
  }), variant: "body2", children: [t('search.total', { count }), " ", _jsx(Link, { to: `/hits?query=${encodeURIComponent(query)}`, children: t('search.open') })] }));
14
14
  };
@@ -5,7 +5,6 @@ import api from '@cccsaurora/howler-ui/api';
5
5
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
6
  import ConfirmDeleteModal from '@cccsaurora/howler-ui/components/elements/display/modals/ConfirmDeleteModal';
7
7
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
8
- import dayjs from 'dayjs';
9
8
  import { useCallback, useContext, useState } from 'react';
10
9
  import { useTranslation } from 'react-i18next';
11
10
  import { useOutletContext } from 'react-router-dom';
@@ -47,11 +46,10 @@ const CaseRules = ({ case: providedCase, caseId }) => {
47
46
  return null;
48
47
  }
49
48
  const rules = _case.rules ?? [];
50
- return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => {
51
- const _timeframe = rule.timeframe ? dayjs(rule.timeframe) : null;
52
- return (_jsxs(TableRow, { id: `rule-row-${rule.rule_id}`, children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: { fontFamily: 'monospace' }, children: rule.destination }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: {
53
- fontFamily: 'monospace'
54
- }, children: rule.query }) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", spacing: 0.5, children: (rule.indexes ?? ['hit']).map(idx => (_jsx(Chip, { size: "small", label: idx, variant: "outlined" }, idx))) }) }), _jsx(TableCell, { children: _timeframe ? (_jsx(Chip, { size: "small", label: _timeframe.format('YYYY-MM-DD HH:mm'), color: _timeframe.isBefore(dayjs()) ? 'error' : 'default' })) : (_jsx(Chip, { size: "small", label: t('page.cases.rules.no_expiry'), color: "success" })) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", children: rule.author }) }), _jsx(TableCell, { align: "center", children: _jsx(Switch, { id: `rule-toggle-${rule.rule_id}`, checked: rule.enabled ?? true, onChange: (_e, checked) => handleToggleEnabled(rule.rule_id, checked), size: "small" }) }), _jsx(TableCell, { align: "right", children: _jsx(Tooltip, { title: t('delete'), children: _jsx(IconButton, { id: `rule-delete-${rule.rule_id}`, size: "small", onClick: () => handleDeleteRule(rule.rule_id), children: _jsx(Delete, { fontSize: "small" }) }) }) })] }, rule.rule_id));
55
- }) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
49
+ return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => (_jsxs(TableRow, { id: `rule-row-${rule.rule_id}`, children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: { fontFamily: 'monospace' }, children: rule.destination }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: {
50
+ fontFamily: 'monospace'
51
+ }, children: rule.query }) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", spacing: 0.5, children: (rule.indexes ?? ['hit']).map(idx => (_jsx(Chip, { size: "small", label: idx, variant: "outlined" }, idx))) }) }), _jsx(TableCell, { children: rule.timeframe != null ? (_jsx(Chip, { size: "small", label: rule.expire_after_resolved
52
+ ? t('page.cases.rules.timeframe.after_resolved', { days: rule.timeframe })
53
+ : t('page.cases.rules.timeframe.days', { days: rule.timeframe }), color: rule.expire_after_resolved ? 'info' : 'default' })) : (_jsx(Chip, { size: "small", label: t('page.cases.rules.no_expiry'), color: "success" })) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", children: rule.author }) }), _jsx(TableCell, { align: "center", children: _jsx(Switch, { id: `rule-toggle-${rule.rule_id}`, checked: rule.enabled ?? true, onChange: (_e, checked) => handleToggleEnabled(rule.rule_id, checked), size: "small" }) }), _jsx(TableCell, { align: "right", children: _jsx(Tooltip, { title: t('delete'), children: _jsx(IconButton, { id: `rule-delete-${rule.rule_id}`, size: "small", onClick: () => handleDeleteRule(rule.rule_id), children: _jsx(Delete, { fontSize: "small" }) }) }) })] }, rule.rule_id))) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
56
54
  };
57
55
  export default CaseRules;
@@ -68,7 +68,8 @@ const makeRule = (overrides) => ({
68
68
  destination: 'alerts/{{howler.analytic}}',
69
69
  author: 'analyst1',
70
70
  enabled: true,
71
- timeframe: '2026-06-01T00:00:00.000Z',
71
+ timeframe: 14,
72
+ expire_after_resolved: false,
72
73
  indexes: ['hit'],
73
74
  ...overrides
74
75
  });
@@ -104,6 +105,22 @@ describe('CaseRules', () => {
104
105
  render(_jsx(CaseRules, {}));
105
106
  expect(screen.getByText('page.cases.rules.no_expiry')).toBeInTheDocument();
106
107
  });
108
+ it('shows after-resolved chip when expire_after_resolved is true', () => {
109
+ mockCase.current = createMockCase({
110
+ case_id: 'case-001',
111
+ rules: [makeRule({ timeframe: 7, expire_after_resolved: true })]
112
+ });
113
+ render(_jsx(CaseRules, {}));
114
+ expect(screen.getByText('page.cases.rules.timeframe.after_resolved')).toBeInTheDocument();
115
+ });
116
+ it('shows days chip when expire_after_resolved is false', () => {
117
+ mockCase.current = createMockCase({
118
+ case_id: 'case-001',
119
+ rules: [makeRule({ timeframe: 14, expire_after_resolved: false })]
120
+ });
121
+ render(_jsx(CaseRules, {}));
122
+ expect(screen.getByText('page.cases.rules.timeframe.days')).toBeInTheDocument();
123
+ });
107
124
  it('renders enabled toggle for each rule', () => {
108
125
  mockCase.current = createMockCase({
109
126
  case_id: 'case-001',
@@ -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
  });
@@ -391,18 +391,6 @@
391
391
  "page.403.title": "403: Access Forbidden",
392
392
  "page.404.description": "The page you are looking for cannot be found...",
393
393
  "page.404.title": "404: Not found",
394
- "page.cases.observables": "Observables",
395
- "page.cases.observables.empty": "No observables found.",
396
- "page.cases.observables.filter_by_type": "Filter by type:",
397
- "page.cases.observables.seen_in": "Seen in",
398
- "page.cases.observables.type.hash": "Hash",
399
- "page.cases.observables.type.hosts": "Host",
400
- "page.cases.observables.type.id": "ID",
401
- "page.cases.observables.type.ids": "ID",
402
- "page.cases.observables.type.ip": "IP",
403
- "page.cases.observables.type.signature": "Signature",
404
- "page.cases.observables.type.uri": "URI",
405
- "page.cases.observables.type.user": "User",
406
394
  "page.cases.created": "Created",
407
395
  "page.cases.dashboard": "Dashboard",
408
396
  "page.cases.dashboard.alerts": "Alerts",
@@ -418,13 +406,25 @@
418
406
  "page.cases.dashboard.tasks.hide_child": "Hide child case tasks",
419
407
  "page.cases.dashboard.tasks.show_child": "Show child case tasks",
420
408
  "page.cases.dashboard.threat": "Threats",
409
+ "page.cases.detail.escalation": "Escalation",
421
410
  "page.cases.detail.participants": "Participants",
422
411
  "page.cases.detail.properties": "Properties",
423
412
  "page.cases.detail.status": "Status",
424
- "page.cases.detail.escalation": "Escalation",
425
413
  "page.cases.detail.viewers": "Active Viewers",
426
414
  "page.cases.escalation": "Escalation",
427
415
  "page.cases.folder.drop.root": "Place here to move to root",
416
+ "page.cases.observables": "Observables",
417
+ "page.cases.observables.empty": "No observables found.",
418
+ "page.cases.observables.filter_by_type": "Filter by type:",
419
+ "page.cases.observables.seen_in": "Seen in",
420
+ "page.cases.observables.type.hash": "Hash",
421
+ "page.cases.observables.type.hosts": "Host",
422
+ "page.cases.observables.type.id": "ID",
423
+ "page.cases.observables.type.ids": "ID",
424
+ "page.cases.observables.type.ip": "IP",
425
+ "page.cases.observables.type.signature": "Signature",
426
+ "page.cases.observables.type.uri": "URI",
427
+ "page.cases.observables.type.user": "User",
428
428
  "page.cases.rules": "Rules",
429
429
  "page.cases.rules.author": "Author",
430
430
  "page.cases.rules.create": "Create Rule",
@@ -432,9 +432,14 @@
432
432
  "page.cases.rules.destination": "Alert Destination Path",
433
433
  "page.cases.rules.destination.help": "Handlebars template for the case item path (e.g. alerts/{{howler.analytic}})",
434
434
  "page.cases.rules.empty": "No correlation rules configured.",
435
+ "page.cases.rules.expire_after_resolved": "Start expiry after case is resolved",
436
+ "page.cases.rules.indexes": "Indexes",
435
437
  "page.cases.rules.no_expiry": "No expiry",
436
438
  "page.cases.rules.query": "Match Query",
437
439
  "page.cases.rules.timeframe": "Rule Expiry",
440
+ "page.cases.rules.timeframe.after_resolved": "{{days}} day(s) after resolved",
441
+ "page.cases.rules.timeframe.days": "{{days}} day(s)",
442
+ "page.cases.rules.timeframe.help": "Number of days before the rule expires",
438
443
  "page.cases.search": "Search",
439
444
  "page.cases.sidebar.folder.remove": "Remove folder",
440
445
  "page.cases.sidebar.item.open": "Open item",
@@ -391,18 +391,6 @@
391
391
  "page.403.title": "403: Accès interdit",
392
392
  "page.404.description": "La page que vous recherchez est introuvable ...",
393
393
  "page.404.title": "404: Introuvable",
394
- "page.cases.observables": "Observables",
395
- "page.cases.observables.empty": "Aucun observable trouvé.",
396
- "page.cases.observables.filter_by_type": "Filtrer par type :",
397
- "page.cases.observables.seen_in": "Vu dans",
398
- "page.cases.observables.type.hash": "Hachage",
399
- "page.cases.observables.type.hosts": "Hôte",
400
- "page.cases.observables.type.id": "Identifiant",
401
- "page.cases.observables.type.ids": "Identifiant",
402
- "page.cases.observables.type.ip": "IP",
403
- "page.cases.observables.type.signature": "Signature",
404
- "page.cases.observables.type.uri": "URI",
405
- "page.cases.observables.type.user": "Utilisateur",
406
394
  "page.cases.created": "Créé",
407
395
  "page.cases.dashboard": "Tableau de bord",
408
396
  "page.cases.dashboard.alerts": "Alertes",
@@ -418,13 +406,25 @@
418
406
  "page.cases.dashboard.tasks.hide_child": "Masquer les tâches des cas enfants",
419
407
  "page.cases.dashboard.tasks.show_child": "Afficher les tâches des cas enfants",
420
408
  "page.cases.dashboard.threat": "Menaces",
409
+ "page.cases.detail.escalation": "Escalade",
421
410
  "page.cases.detail.participants": "Participants",
422
411
  "page.cases.detail.properties": "Propriétés",
423
412
  "page.cases.detail.status": "Statut",
424
- "page.cases.detail.escalation": "Escalade",
425
413
  "page.cases.detail.viewers": "Spectateurs actifs",
426
414
  "page.cases.escalation": "Escalade",
427
415
  "page.cases.folder.drop.root": "Déposer ici pour déplacer à la racine",
416
+ "page.cases.observables": "Observables",
417
+ "page.cases.observables.empty": "Aucun observable trouvé.",
418
+ "page.cases.observables.filter_by_type": "Filtrer par type :",
419
+ "page.cases.observables.seen_in": "Vu dans",
420
+ "page.cases.observables.type.hash": "Hachage",
421
+ "page.cases.observables.type.hosts": "Hôte",
422
+ "page.cases.observables.type.id": "Identifiant",
423
+ "page.cases.observables.type.ids": "Identifiant",
424
+ "page.cases.observables.type.ip": "IP",
425
+ "page.cases.observables.type.signature": "Signature",
426
+ "page.cases.observables.type.uri": "URI",
427
+ "page.cases.observables.type.user": "Utilisateur",
428
428
  "page.cases.rules": "Règles",
429
429
  "page.cases.rules.author": "Auteur",
430
430
  "page.cases.rules.create": "Créer une règle",
@@ -432,9 +432,14 @@
432
432
  "page.cases.rules.destination": "Chemin de destination des alertes",
433
433
  "page.cases.rules.destination.help": "Modèle Handlebars pour le chemin de l'élément de cas (ex. alerts/{{howler.analytic}})",
434
434
  "page.cases.rules.empty": "Aucune règle de corrélation configurée.",
435
+ "page.cases.rules.expire_after_resolved": "Démarrer l'expiration après la résolution du cas",
436
+ "page.cases.rules.indexes": "Index",
435
437
  "page.cases.rules.no_expiry": "Sans expiration",
436
438
  "page.cases.rules.query": "Requête de correspondance",
437
439
  "page.cases.rules.timeframe": "Expiration de la règle",
440
+ "page.cases.rules.timeframe.after_resolved": "{{days}} jour(s) après résolution",
441
+ "page.cases.rules.timeframe.days": "{{days}} jour(s)",
442
+ "page.cases.rules.timeframe.help": "Nombre de jours avant l'expiration de la règle",
438
443
  "page.cases.search": "Rechercher",
439
444
  "page.cases.sidebar.folder.remove": "Supprimer le dossier",
440
445
  "page.cases.sidebar.item.open": "Ouvrir l'élément",
@@ -7,6 +7,8 @@ export interface Rule {
7
7
  query?: string;
8
8
  author?: string;
9
9
  enabled?: boolean;
10
- timeframe?: string;
10
+ created_at?: string;
11
+ timeframe?: number;
12
+ expire_after_resolved?: boolean;
11
13
  indexes?: ('hit' | 'event')[];
12
14
  }
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.19.0-cases.1122",
99
+ "version": "2.19.0-cases.1129",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",