@cccsaurora/howler-ui 3.1.0-dev.1705 → 3.1.0-dev.1708

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.
@@ -0,0 +1,3 @@
1
+ import type { SearchField } from '@cccsaurora/howler-ui/api/search/fields';
2
+ export declare const uri: () => string;
3
+ export declare const get: () => Promise<SearchField[]>;
@@ -0,0 +1,8 @@
1
+ import { hget, joinUri } from '@cccsaurora/howler-ui/api';
2
+ import { indexed, uri as parentUri } from '@cccsaurora/howler-ui/api/search/fields';
3
+ export const uri = () => {
4
+ return joinUri(parentUri(), 'event');
5
+ };
6
+ export const get = async () => {
7
+ return indexed((await hget(uri())) ?? {});
8
+ };
@@ -1,3 +1,4 @@
1
+ import * as event from '@cccsaurora/howler-ui/api/search/fields/event';
1
2
  import * as hit from '@cccsaurora/howler-ui/api/search/fields/hit';
2
3
  import * as user from '@cccsaurora/howler-ui/api/search/fields/user';
3
4
  export type SearchField = {
@@ -8,6 +9,10 @@ export type SearchField = {
8
9
  stored: boolean;
9
10
  type: string;
10
11
  description?: string;
12
+ deprecated?: boolean;
13
+ deprecated_description?: string;
14
+ regex?: string;
15
+ values?: string[];
11
16
  };
12
17
  export declare const uri: () => string;
13
18
  export declare const map: (fields: {
@@ -16,4 +21,4 @@ export declare const map: (fields: {
16
21
  export declare const indexed: (fields: {
17
22
  [key: string]: SearchField;
18
23
  }) => SearchField[];
19
- export { hit, user };
24
+ export { event, hit, user };
@@ -1,4 +1,5 @@
1
1
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/search';
2
+ import * as event from '@cccsaurora/howler-ui/api/search/fields/event';
2
3
  import * as hit from '@cccsaurora/howler-ui/api/search/fields/hit';
3
4
  import * as user from '@cccsaurora/howler-ui/api/search/fields/user';
4
5
  // import urlJoin from 'url-join';
@@ -12,4 +13,4 @@ export const map = (fields) => {
12
13
  export const indexed = (fields) => {
13
14
  return map(fields).filter(field => field.indexed);
14
15
  };
15
- export { hit, user };
16
+ export { event, hit, user };
@@ -0,0 +1,4 @@
1
+ export declare const uri: (caseId: string, ruleId: string) => string;
2
+ export declare const post: (caseId: string, ruleId: string, since: string) => Promise<{
3
+ count: number;
4
+ } | undefined>;
@@ -0,0 +1,8 @@
1
+ import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
2
+ import { uri as caseUri } from '@cccsaurora/howler-ui/api/v2/case';
3
+ export const uri = (caseId, ruleId) => {
4
+ return joinAllUri(caseUri(caseId), 'rules', ruleId, 'backfill', 'count');
5
+ };
6
+ export const post = (caseId, ruleId, since) => {
7
+ return hpost(uri(caseId, ruleId), { since });
8
+ };
@@ -0,0 +1,6 @@
1
+ import * as count from './count';
2
+ export declare const uri: (caseId: string, ruleId: string) => string;
3
+ export declare const post: (caseId: string, ruleId: string, since: string) => Promise<{
4
+ queued: number;
5
+ } | undefined>;
6
+ export { count };
@@ -0,0 +1,10 @@
1
+ import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
2
+ import { uri as caseUri } from '@cccsaurora/howler-ui/api/v2/case';
3
+ import * as count from './count';
4
+ export const uri = (caseId, ruleId) => {
5
+ return joinAllUri(caseUri(caseId), 'rules', ruleId, 'backfill');
6
+ };
7
+ export const post = (caseId, ruleId, since) => {
8
+ return hpost(uri(caseId, ruleId), { since });
9
+ };
10
+ export { count };
@@ -1,6 +1,8 @@
1
+ import * as backfill from '@cccsaurora/howler-ui/api/v2/case/rules/backfill';
1
2
  import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
3
  import type { Rule } from '@cccsaurora/howler-ui/models/entities/generated/Rule';
3
4
  export declare const uri: (caseId: string, ruleId?: string) => string;
4
5
  export declare const post: (caseId: string, ruleData: Partial<Rule>) => Promise<Case | undefined>;
5
6
  export declare const del: (caseId: string, ruleId: string) => Promise<Case | undefined>;
6
7
  export declare const put: (caseId: string, ruleId: string, data: Partial<Rule>) => Promise<Case | undefined>;
8
+ export { backfill };
@@ -1,6 +1,7 @@
1
1
  // eslint-disable-next-line import/no-cycle
2
2
  import { hdelete, hpost, hput, joinAllUri } from '@cccsaurora/howler-ui/api';
3
3
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2/case';
4
+ import * as backfill from '@cccsaurora/howler-ui/api/v2/case/rules/backfill';
4
5
  export const uri = (caseId, ruleId) => {
5
6
  if (ruleId) {
6
7
  return joinAllUri(parentUri(caseId), 'rules', ruleId);
@@ -16,3 +17,4 @@ export const del = (caseId, ruleId) => {
16
17
  export const put = (caseId, ruleId, data) => {
17
18
  return hput(uri(caseId, ruleId), data);
18
19
  };
20
+ export { backfill };
package/api/v2/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
2
2
  import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
3
+ import * as ingest from '@cccsaurora/howler-ui/api/v2/ingest';
3
4
  import * as search from '@cccsaurora/howler-ui/api/v2/search';
4
5
  export declare const uri: () => string;
5
- export { case_ as case, fuzzy, search };
6
+ export { case_ as case, fuzzy, ingest, search };
package/api/v2/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
2
2
  import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
3
+ import * as ingest from '@cccsaurora/howler-ui/api/v2/ingest';
3
4
  import * as search from '@cccsaurora/howler-ui/api/v2/search';
4
5
  export const uri = () => {
5
6
  return '/api/v2';
6
7
  };
7
- export { case_ as case, fuzzy, search };
8
+ export { case_ as case, fuzzy, ingest, search };
@@ -0,0 +1,3 @@
1
+ import { type HowlerRefreshParam } from '@cccsaurora/howler-ui/api';
2
+ export declare const uri: (index: string) => string;
3
+ export declare const post: (index: string, records: Record<string, unknown>[], refresh?: HowlerRefreshParam) => Promise<string[] | undefined>;
@@ -0,0 +1,8 @@
1
+ import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
2
+ import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2';
3
+ export const uri = (index) => {
4
+ return joinAllUri(parentUri(), 'ingest', index);
5
+ };
6
+ export const post = (index, records, refresh) => {
7
+ return hpost(uri(index), records, undefined, refresh ? new URLSearchParams({ refresh }) : undefined);
8
+ };
@@ -7,6 +7,6 @@ import { Link } from 'react-router';
7
7
  const EventPreview = ({ event }) => {
8
8
  const { t } = useTranslation();
9
9
  const theme = useTheme();
10
- return (_jsx(Stack, { flex: 1, spacing: 1, sx: { overflow: 'hidden', borderBottom: `thin solid ${theme.palette.divider}`, pb: 1, mb: 0 }, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "body1", fontWeight: "bold", children: event.event.provider }), _jsx("div", { style: { flex: 1 } }), _jsx(Chip, { label: event.event.kind }), event.event.reference && (_jsx(IconButton, { size: "small", component: Link, to: event.event.reference, sx: { opacity: 1 }, target: "_blank", rel: "noopener noreferrer", children: _jsx(OpenInNew, { fontSize: "small" }) }))] }), event.event.type && (_jsxs(Typography, { variant: "caption", children: [t('event.type'), " - ", event.event.type.join(', ')] })), event.event.module && (_jsxs(Typography, { variant: "caption", children: [t('event.module'), " - ", event.event.module] }))] }) }));
10
+ return (_jsx(Stack, { flex: 1, spacing: 1, sx: { overflow: 'hidden', borderBottom: `thin solid ${theme.palette.divider}`, pb: 1, mb: 0 }, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "body1", fontWeight: "bold", children: event.message || event.event.provider }), _jsx("div", { style: { flex: 1 } }), _jsx(Chip, { label: event.event.kind }), event.event.reference && (_jsx(IconButton, { size: "small", component: Link, to: event.event.reference, sx: { opacity: 1 }, target: "_blank", rel: "noopener noreferrer", children: _jsx(OpenInNew, { fontSize: "small" }) }))] }), event.message && event.event.provider && (_jsxs(Typography, { variant: "caption", children: [t('event.provider'), " - ", event.event.provider] })), event.event.type && (_jsxs(Typography, { variant: "caption", children: [t('event.type'), " - ", event.event.type.join(', ')] })), event.event.module && (_jsxs(Typography, { variant: "caption", children: [t('event.module'), " - ", event.event.module] }))] }) }));
11
11
  };
12
12
  export default memo(EventPreview);
@@ -1,11 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Add, Delete } from '@mui/icons-material';
3
- import { Button, Card, Chip, IconButton, Stack, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip, Typography } from '@mui/material';
2
+ import { Add, Delete, History } from '@mui/icons-material';
3
+ import { Button, Card, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip, Typography } from '@mui/material';
4
+ import { LocalizationProvider } from '@mui/x-date-pickers';
5
+ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
6
+ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
4
7
  import api from '@cccsaurora/howler-ui/api';
5
8
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
9
  import ConfirmDeleteModal from '@cccsaurora/howler-ui/components/elements/display/modals/ConfirmDeleteModal';
7
10
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
8
- import { useCallback, useContext, useState } from 'react';
11
+ import dayjs, {} from 'dayjs';
12
+ import { useCallback, useContext, useRef, useState } from 'react';
9
13
  import { useTranslation } from 'react-i18next';
10
14
  import { useOutletContext } from 'react-router';
11
15
  import useCase from '../hooks/useCase';
@@ -17,6 +21,58 @@ const CaseRules = ({ case: providedCase, caseId }) => {
17
21
  const routeCase = useOutletContext();
18
22
  const { case: _case, update } = useCase({ case: providedCase ?? routeCase, caseId });
19
23
  const [dialogOpen, setDialogOpen] = useState(false);
24
+ const [backfillRule, setBackfillRule] = useState();
25
+ const [backfillSince, setBackfillSince] = useState(dayjs().subtract(30, 'day'));
26
+ const [backfillCount, setBackfillCount] = useState(null);
27
+ const [backfillLoading, setBackfillLoading] = useState(false);
28
+ const backfillCountRequestId = useRef(0);
29
+ const invalidateBackfillCountRequest = useCallback(() => {
30
+ backfillCountRequestId.current += 1;
31
+ setBackfillLoading(false);
32
+ }, []);
33
+ const handleBackfillDialogClose = useCallback(() => {
34
+ invalidateBackfillCountRequest();
35
+ setBackfillRule(undefined);
36
+ setBackfillCount(null);
37
+ setBackfillSince(dayjs().subtract(30, 'day'));
38
+ }, [invalidateBackfillCountRequest]);
39
+ const handleCountBackfill = useCallback(async () => {
40
+ if (!_case?.case_id || !backfillRule?.rule_id || !backfillSince?.isValid()) {
41
+ return;
42
+ }
43
+ const requestId = ++backfillCountRequestId.current;
44
+ setBackfillLoading(true);
45
+ try {
46
+ const response = await dispatchApi(api.v2.case.rules.backfill.count.post(_case.case_id, backfillRule.rule_id, backfillSince.toISOString()));
47
+ if (response && requestId === backfillCountRequestId.current) {
48
+ setBackfillCount(response.count);
49
+ }
50
+ }
51
+ finally {
52
+ if (requestId === backfillCountRequestId.current) {
53
+ setBackfillLoading(false);
54
+ }
55
+ }
56
+ }, [_case?.case_id, backfillRule, backfillSince, dispatchApi]);
57
+ const handleBackfillDialogOpen = useCallback((rule) => {
58
+ invalidateBackfillCountRequest();
59
+ setBackfillRule(rule);
60
+ setBackfillCount(null);
61
+ setBackfillSince(dayjs().subtract(30, 'day'));
62
+ }, [invalidateBackfillCountRequest]);
63
+ const handleSubmitBackfill = useCallback(async () => {
64
+ if (!_case?.case_id || !backfillRule?.rule_id || !backfillSince?.isValid() || backfillCount === null) {
65
+ return;
66
+ }
67
+ setBackfillLoading(true);
68
+ try {
69
+ await dispatchApi(api.v2.case.rules.backfill.post(_case.case_id, backfillRule.rule_id, backfillSince.toISOString()));
70
+ handleBackfillDialogClose();
71
+ }
72
+ finally {
73
+ setBackfillLoading(false);
74
+ }
75
+ }, [_case?.case_id, backfillRule, backfillSince, backfillCount, dispatchApi, handleBackfillDialogClose]);
20
76
  const handleCreateRule = useCallback(async (ruleData) => {
21
77
  if (!_case?.case_id) {
22
78
  return;
@@ -54,6 +110,13 @@ const CaseRules = ({ case: providedCase, caseId }) => {
54
110
  fontFamily: 'monospace'
55
111
  }, 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
56
112
  ? t('page.cases.rules.timeframe.after_resolved', { days: rule.timeframe })
57
- : 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 })] }));
113
+ : 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" }) }), _jsxs(TableCell, { align: "right", children: [_jsx(Tooltip, { title: t('page.cases.rules.backfill'), children: _jsx(IconButton, { id: `rule-backfill-${rule.rule_id}`, size: "small", disabled: rule.enabled === false, onClick: () => handleBackfillDialogOpen(rule), children: _jsx(History, { fontSize: "small" }) }) }), _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 }), _jsxs(Dialog, { open: !!backfillRule, onClose: handleBackfillDialogClose, maxWidth: "sm", fullWidth: true, id: "rule-backfill-dialog", children: [_jsx(DialogTitle, { children: t('page.cases.rules.backfill') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 2, sx: { mt: 1 }, children: [_jsx(Typography, { children: t('page.cases.rules.backfill.description') }), _jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsx(DateTimePicker, { label: t('page.cases.rules.backfill.since'), value: backfillSince, onChange: value => {
114
+ if (!value) {
115
+ return;
116
+ }
117
+ invalidateBackfillCountRequest();
118
+ setBackfillSince(value);
119
+ setBackfillCount(null);
120
+ }, maxDateTime: dayjs(), ampm: false, slotProps: { textField: { id: 'rule-backfill-since', size: 'small', fullWidth: true } } }) }), backfillCount !== null && (_jsx(Typography, { id: "rule-backfill-confirmation", children: t('page.cases.rules.backfill.confirm', { count: backfillCount }) }))] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: handleBackfillDialogClose, children: t('cancel') }), backfillCount === null ? (_jsx(Button, { id: "rule-backfill-count-button", variant: "contained", onClick: handleCountBackfill, disabled: backfillLoading || !backfillSince?.isValid(), children: t('page.cases.rules.backfill.preview') })) : (_jsx(Button, { id: "rule-backfill-submit-button", variant: "contained", onClick: handleSubmitBackfill, disabled: backfillLoading || backfillCount === 0, children: t('page.cases.rules.backfill.submit') }))] })] })] }));
58
121
  };
59
122
  export default CaseRules;
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
6
6
  const mockDispatchApi = vi.hoisted(() => vi.fn());
7
7
  const mockUpdate = vi.hoisted(() => vi.fn());
8
8
  const mockShowModal = vi.hoisted(() => vi.fn());
9
+ const mockTranslate = vi.hoisted(() => vi.fn((key, options) => (options?.count === undefined ? key : `${key}:${options.count}`)));
9
10
  const mockCase = vi.hoisted(() => ({
10
11
  current: {
11
12
  case_id: 'case-001',
@@ -16,6 +17,9 @@ const mockCase = vi.hoisted(() => ({
16
17
  vi.mock('components/hooks/useMyApi', () => ({
17
18
  default: () => ({ dispatchApi: mockDispatchApi })
18
19
  }));
20
+ vi.mock('react-i18next', () => ({
21
+ useTranslation: () => ({ t: mockTranslate })
22
+ }));
19
23
  vi.mock('components/app/providers/ModalProvider', async () => {
20
24
  const { createContext } = await import('react');
21
25
  return {
@@ -47,6 +51,12 @@ vi.mock('./CreateRuleDialog', () => ({
47
51
  return open ? (_jsxs("div", { id: "create-rule-dialog", children: [_jsx("button", { id: "rule-submit-button", onClick: () => onSubmit({ query: 'event.kind:alert', destination: 'alerts/incoming' }), children: "submit" }), _jsx("button", { onClick: onClose, children: "cancel" })] })) : null;
48
52
  }
49
53
  }));
54
+ vi.mock('@mui/x-date-pickers/DateTimePicker', async () => {
55
+ const { default: dayjs } = await import('dayjs');
56
+ return {
57
+ DateTimePicker: ({ onChange }) => (_jsx("button", { id: "rule-backfill-since", onClick: () => onChange(dayjs('2025-06-01')), children: "pick date" }))
58
+ };
59
+ });
50
60
  vi.mock('api', () => ({
51
61
  default: {
52
62
  v2: {
@@ -54,7 +64,11 @@ vi.mock('api', () => ({
54
64
  rules: {
55
65
  post: vi.fn(),
56
66
  del: vi.fn(),
57
- put: vi.fn()
67
+ put: vi.fn(),
68
+ backfill: {
69
+ post: vi.fn(),
70
+ count: { post: vi.fn() }
71
+ }
58
72
  }
59
73
  }
60
74
  }
@@ -73,6 +87,13 @@ const makeRule = (overrides) => ({
73
87
  indexes: ['hit'],
74
88
  ...overrides
75
89
  });
90
+ const createDeferred = () => {
91
+ let resolve;
92
+ const promise = new Promise(resolvePromise => {
93
+ resolve = resolvePromise;
94
+ });
95
+ return { promise, resolve };
96
+ };
76
97
  describe('CaseRules', () => {
77
98
  beforeEach(() => {
78
99
  vi.clearAllMocks();
@@ -222,6 +243,72 @@ describe('CaseRules', () => {
222
243
  expect(mockDispatchApi).toHaveBeenCalledWith('put-request');
223
244
  });
224
245
  });
246
+ it('previews and confirms the number of matching alerts before backfilling', async () => {
247
+ const user = userEvent.setup();
248
+ mockCase.current = createMockCase({
249
+ case_id: 'case-001',
250
+ rules: [makeRule()]
251
+ });
252
+ mockDispatchApi.mockResolvedValueOnce({ count: 23 }).mockResolvedValueOnce({ queued: 23 });
253
+ vi.mocked(api.v2.case.rules.backfill.count.post).mockReturnValue('count-backfill-request');
254
+ vi.mocked(api.v2.case.rules.backfill.post).mockReturnValue('backfill-request');
255
+ render(_jsx(CaseRules, {}));
256
+ await user.click(screen.getByTestId('rule-backfill-rule-001'));
257
+ await user.click(screen.getByTestId('rule-backfill-count-button'));
258
+ await waitFor(() => {
259
+ expect(screen.getByTestId('rule-backfill-confirmation')).toHaveTextContent('page.cases.rules.backfill.confirm:23');
260
+ });
261
+ expect(mockDispatchApi).toHaveBeenCalledWith('count-backfill-request');
262
+ await user.click(screen.getByTestId('rule-backfill-submit-button'));
263
+ await waitFor(() => {
264
+ expect(mockDispatchApi).toHaveBeenCalledWith('backfill-request');
265
+ expect(screen.queryByTestId('rule-backfill-dialog')).not.toBeInTheDocument();
266
+ });
267
+ });
268
+ it('ignores a pending count response after closing and reopening for another rule', async () => {
269
+ const user = userEvent.setup();
270
+ mockCase.current = createMockCase({
271
+ case_id: 'case-001',
272
+ rules: [makeRule(), makeRule({ rule_id: 'rule-002' })]
273
+ });
274
+ const pendingCount = createDeferred();
275
+ mockDispatchApi.mockReturnValueOnce(pendingCount.promise);
276
+ vi.mocked(api.v2.case.rules.backfill.count.post).mockReturnValue('count-backfill-request');
277
+ render(_jsx(CaseRules, {}));
278
+ await user.click(screen.getByTestId('rule-backfill-rule-001'));
279
+ await user.click(screen.getByTestId('rule-backfill-count-button'));
280
+ await user.click(screen.getByText('cancel'));
281
+ await waitFor(() => {
282
+ expect(screen.queryByTestId('rule-backfill-dialog')).not.toBeInTheDocument();
283
+ });
284
+ await user.click(screen.getByTestId('rule-backfill-rule-002'));
285
+ await act(async () => {
286
+ pendingCount.resolve({ count: 23 });
287
+ await pendingCount.promise;
288
+ });
289
+ expect(screen.getByTestId('rule-backfill-count-button')).toBeInTheDocument();
290
+ expect(screen.queryByTestId('rule-backfill-confirmation')).not.toBeInTheDocument();
291
+ });
292
+ it('ignores a pending count response after the preview date changes', async () => {
293
+ const user = userEvent.setup();
294
+ mockCase.current = createMockCase({
295
+ case_id: 'case-001',
296
+ rules: [makeRule()]
297
+ });
298
+ const pendingCount = createDeferred();
299
+ mockDispatchApi.mockReturnValueOnce(pendingCount.promise);
300
+ vi.mocked(api.v2.case.rules.backfill.count.post).mockReturnValue('count-backfill-request');
301
+ render(_jsx(CaseRules, {}));
302
+ await user.click(screen.getByTestId('rule-backfill-rule-001'));
303
+ await user.click(screen.getByTestId('rule-backfill-count-button'));
304
+ await user.click(screen.getByTestId('rule-backfill-since'));
305
+ await act(async () => {
306
+ pendingCount.resolve({ count: 23 });
307
+ await pendingCount.promise;
308
+ });
309
+ expect(screen.getByTestId('rule-backfill-count-button')).toBeEnabled();
310
+ expect(screen.queryByTestId('rule-backfill-confirmation')).not.toBeInTheDocument();
311
+ });
225
312
  it('renders multiple rules', () => {
226
313
  mockCase.current = createMockCase({
227
314
  case_id: 'case-001',
@@ -1,10 +1,11 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DndContext, DragOverlay, MouseSensor, pointerWithin, TouchSensor, useSensor, useSensors } from '@dnd-kit/core';
3
- import { AddCircle, CalendarMonth, Circle, Dashboard, Dataset, Description, Folder, Refresh, Rule, Search, UnfoldLess } from '@mui/icons-material';
3
+ import { AddCircle, CalendarMonth, Circle, Dashboard, Dataset, Description, Event as EventIcon, Folder, Refresh, Rule, Search, UnfoldLess } from '@mui/icons-material';
4
4
  import { alpha, Box, Card, Chip, IconButton, LinearProgress, Skeleton, Stack, Tooltip, Typography, useTheme } from '@mui/material';
5
5
  import api from '@cccsaurora/howler-ui/api';
6
6
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
7
7
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
8
+ import AddEventModal from '@cccsaurora/howler-ui/components/routes/cases/modals/AddEventModal';
8
9
  import AddToCaseModal from '@cccsaurora/howler-ui/components/routes/cases/modals/AddToCaseModal';
9
10
  import dayjs from 'dayjs';
10
11
  import { useCallback, useContext, useReducer, useState } from 'react';
@@ -90,13 +91,27 @@ const CaseSidebar = ({ case: _case, update }) => {
90
91
  setLoading(false);
91
92
  }
92
93
  }, [_case, dispatchApi, update]);
94
+ if (!_case) {
95
+ return null;
96
+ }
93
97
  return (_jsxs(Box, { sx: {
94
98
  flex: 1,
95
99
  maxWidth: '350px',
96
100
  maxHeight: 'calc(100vh - 64px)',
97
101
  display: 'flex',
98
102
  flexDirection: 'column'
99
- }, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _case ? (_jsxs(_Fragment, { children: [_jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith(_case.case_id)), component: Link, to: `/cases/${_case.case_id}`, children: [_jsx(Dashboard, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { pl: 1, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('search')), component: Link, to: `/cases/${_case.case_id}/search`, children: [_jsx(Search, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.search') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('observables')), component: Link, to: `/cases/${_case.case_id}/observables`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.observables') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('timeline')), component: Link, to: `/cases/${_case.case_id}/timeline`, children: [_jsx(CalendarMonth, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('rules')), component: Link, to: `/cases/${_case.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.rules') })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 })] })), _jsx(Card, { sx: { borderRadius: 0, p: 0.25 }, children: _jsxs(Stack, { direction: "row", spacing: 0.25, children: [_jsx("div", { style: { flex: 1 } }), _jsx(Tooltip, { title: t('page.cases.sidebar.add_item'), children: _jsxs(IconButton, { size: "small", sx: { position: 'relative' }, onClick: () => {
103
+ }, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _case ? (_jsxs(_Fragment, { children: [_jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith(_case.case_id)), component: Link, to: `/cases/${_case.case_id}`, children: [_jsx(Dashboard, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { pl: 1, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('search')), component: Link, to: `/cases/${_case.case_id}/search`, children: [_jsx(Search, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.search') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('observables')), component: Link, to: `/cases/${_case.case_id}/observables`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.observables') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('timeline')), component: Link, to: `/cases/${_case.case_id}/timeline`, children: [_jsx(CalendarMonth, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('rules')), component: Link, to: `/cases/${_case.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.rules') })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 }), _jsx(Skeleton, { height: 28 })] })), _jsx(Card, { sx: { borderRadius: 0, p: 0.25 }, children: _jsxs(Stack, { direction: "row", spacing: 0.25, children: [_jsx("div", { style: { flex: 1 } }), _jsx(Tooltip, { title: t('page.cases.sidebar.add_event'), children: _jsxs(IconButton, { size: "small", sx: { position: 'relative' }, onClick: () => {
104
+ if (_case) {
105
+ showModal(_jsx(AddEventModal, { case: _case, onUpdated: update }), { maxHeight: '90vh', height: 'unset' });
106
+ }
107
+ }, children: [_jsx(EventIcon, { sx: { fontSize: '18px' } }), _jsx(AddCircle, { sx: {
108
+ fontSize: '12px',
109
+ position: 'absolute',
110
+ bottom: 2,
111
+ right: 2,
112
+ backgroundColor: theme.palette.background.paper,
113
+ borderRadius: '100%'
114
+ }, htmlColor: "grey" })] }) }), _jsx(Tooltip, { title: t('page.cases.sidebar.add_item'), children: _jsxs(IconButton, { size: "small", sx: { position: 'relative' }, onClick: () => {
100
115
  if (_case) {
101
116
  showModal(_jsx(AddToCaseModal, { case: _case, onUpdated: update }), { maxHeight: '90vh', height: 'unset' });
102
117
  }
@@ -0,0 +1,9 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import { type FC } from 'react';
3
+ type AddEventModalProps = {
4
+ case: Case;
5
+ parentId?: string;
6
+ onUpdated?: (updatedCase: Case) => void;
7
+ };
8
+ declare const AddEventModal: FC<AddEventModalProps>;
9
+ export default AddEventModal;
@@ -0,0 +1,213 @@
1
+ import { createElement as _createElement } from "react";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Add, Delete } from '@mui/icons-material';
4
+ import { Autocomplete, Box, Button, CircularProgress, Divider, IconButton, Stack, TextField, Typography } from '@mui/material';
5
+ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
6
+ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
7
+ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
8
+ import api from '@cccsaurora/howler-ui/api';
9
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
10
+ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
11
+ import { unflatten } from 'flat';
12
+ import Fuse from 'fuse.js';
13
+ import { useContext, useEffect, useMemo, useState } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { useFolderOptions } from './hooks';
16
+ const ESCALATIONS = ['hit', 'alert', 'evidence'];
17
+ const MANAGED_FIELDS = new Set([
18
+ 'event.created',
19
+ 'event.id',
20
+ 'event.ingested',
21
+ 'event.kind',
22
+ 'event.provider',
23
+ 'howler.escalation',
24
+ 'howler.hash',
25
+ 'howler.id',
26
+ 'howler.outline.indicators',
27
+ 'howler.outline.summary',
28
+ 'howler.outline.target',
29
+ 'howler.outline.threat',
30
+ 'howler.related',
31
+ 'message',
32
+ 'classification',
33
+ 'timestamp'
34
+ ]);
35
+ const isBlank = (value) => {
36
+ if (Array.isArray(value)) {
37
+ return value.length === 0;
38
+ }
39
+ return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
40
+ };
41
+ const getFieldOptions = (fields) => fields.filter(field => {
42
+ const key = field.key ?? '';
43
+ return (key &&
44
+ !MANAGED_FIELDS.has(key) &&
45
+ field.type !== 'object' &&
46
+ !key.startsWith('howler.comment.') &&
47
+ !key.startsWith('howler.log.'));
48
+ });
49
+ const parseList = (value) => value
50
+ .split(/[\n,]/)
51
+ .map(entry => entry.trim())
52
+ .filter(Boolean);
53
+ const searchFields = (fields, query) => {
54
+ if (!query.trim()) {
55
+ return fields;
56
+ }
57
+ const tokens = query.toLowerCase().trim().split(/\s+/);
58
+ const tokenMatches = fields.filter(field => {
59
+ const searchable = `${field.key ?? ''} ${field.description ?? ''}`.toLowerCase();
60
+ return tokens.every(token => searchable.includes(token));
61
+ });
62
+ const fuzzyMatches = new Fuse(fields, {
63
+ keys: ['key', 'description'],
64
+ ignoreLocation: true,
65
+ threshold: 0.4
66
+ })
67
+ .search(query)
68
+ .map(result => result.item);
69
+ return [...tokenMatches, ...fuzzyMatches.filter(field => !tokenMatches.includes(field))];
70
+ };
71
+ const FieldInput = ({ field, value, onChange }) => {
72
+ const label = field.key ?? '';
73
+ const values = field.values ?? [];
74
+ if (values.length > 0) {
75
+ if (field.list) {
76
+ return (_jsx(Autocomplete, { multiple: true, options: values, value: Array.isArray(value) ? value.map(String) : [], onChange: (_, nextValue) => onChange(nextValue), renderInput: params => _jsx(TextField, { ...params, size: "small", label: label, helperText: field.description }) }));
77
+ }
78
+ return (_jsx(Autocomplete, { options: values, value: typeof value === 'string' ? value : null, onChange: (_, nextValue) => onChange(nextValue ?? undefined), renderInput: params => _jsx(TextField, { ...params, size: "small", label: label, helperText: field.description }) }));
79
+ }
80
+ if (field.type === 'boolean') {
81
+ return (_jsx(Autocomplete, { options: ['true', 'false'], value: typeof value === 'boolean' ? String(value) : null, onChange: (_, nextValue) => onChange(nextValue === null ? undefined : nextValue === 'true'), renderInput: params => _jsx(TextField, { ...params, size: "small", label: label, helperText: field.description }) }));
82
+ }
83
+ if (field.list) {
84
+ return (_jsx(TextField, { size: "small", label: label, helperText: field.description ?? 'Enter one value per line.', value: Array.isArray(value) ? value.join('\n') : '', onChange: event => onChange(parseList(event.target.value)), multiline: true }));
85
+ }
86
+ const numeric = ['integer', 'long', 'float', 'double', 'number'].includes(field.type);
87
+ return (_jsx(TextField, { size: "small", label: label, type: numeric ? 'number' : 'text', helperText: field.description, value: value === undefined ? '' : String(value), onChange: event => {
88
+ const nextValue = event.target.value;
89
+ if (!nextValue) {
90
+ onChange(undefined);
91
+ }
92
+ else if (numeric) {
93
+ onChange(field.type === 'integer' || field.type === 'long' ? Number.parseInt(nextValue, 10) : Number(nextValue));
94
+ }
95
+ else {
96
+ onChange(nextValue);
97
+ }
98
+ } }));
99
+ };
100
+ const AddEventModal = ({ case: _case, parentId, onUpdated }) => {
101
+ const { t } = useTranslation();
102
+ const { dispatchApi } = useMyApi();
103
+ const { close } = useContext(ModalContext);
104
+ const [fields, setFields] = useState([]);
105
+ const [selectedFields, setSelectedFields] = useState([]);
106
+ const [fieldValues, setFieldValues] = useState({});
107
+ const [fieldSearch, setFieldSearch] = useState('');
108
+ const [title, setTitle] = useState('');
109
+ const [created, setCreated] = useState(null);
110
+ const [provider, setProvider] = useState('');
111
+ const [escalation, setEscalation] = useState(null);
112
+ const [target, setTarget] = useState('');
113
+ const [threat, setThreat] = useState('');
114
+ const [indicators, setIndicators] = useState('');
115
+ const [summary, setSummary] = useState('');
116
+ const [selectedParent, setSelectedParent] = useState(parentId);
117
+ const [createdEventId, setCreatedEventId] = useState();
118
+ const [submitting, setSubmitting] = useState(false);
119
+ const folderOptions = useFolderOptions(_case);
120
+ useEffect(() => {
121
+ void dispatchApi(api.search.fields.event.get(), { throwError: false }).then(result => {
122
+ if (result) {
123
+ setFields(getFieldOptions(result));
124
+ }
125
+ });
126
+ }, [dispatchApi]);
127
+ const filteredFields = useMemo(() => searchFields(fields, fieldSearch), [fieldSearch, fields]);
128
+ const hasOutlineValue = Boolean(target.trim() || threat.trim() || parseList(indicators).length);
129
+ const isValid = Boolean(title.trim() && created?.isValid() && provider.trim() && escalation && hasOutlineValue);
130
+ const setOptionalField = (field) => {
131
+ if (!field.key || selectedFields.some(selected => selected.key === field.key)) {
132
+ return;
133
+ }
134
+ setSelectedFields(current => [...current, field]);
135
+ setFieldSearch('');
136
+ };
137
+ const removeOptionalField = (key) => {
138
+ setSelectedFields(current => current.filter(field => field.key !== key));
139
+ setFieldValues(current => {
140
+ return Object.fromEntries(Object.entries(current).filter(([fieldKey]) => fieldKey !== key));
141
+ });
142
+ };
143
+ const buildPayload = () => {
144
+ const payload = {
145
+ classification: _case.classification,
146
+ message: title.trim(),
147
+ 'event.created': created.toISOString(),
148
+ 'event.kind': 'event',
149
+ 'event.provider': provider.trim(),
150
+ 'howler.escalation': escalation,
151
+ 'howler.outline.indicators': parseList(indicators)
152
+ };
153
+ if (target.trim()) {
154
+ payload['howler.outline.target'] = target.trim();
155
+ }
156
+ if (threat.trim()) {
157
+ payload['howler.outline.threat'] = threat.trim();
158
+ }
159
+ if (summary.trim()) {
160
+ payload['howler.outline.summary'] = summary.trim();
161
+ }
162
+ for (const field of selectedFields) {
163
+ const value = field.key ? fieldValues[field.key] : undefined;
164
+ if (field.key && !isBlank(value)) {
165
+ payload[field.key] = value;
166
+ }
167
+ }
168
+ return unflatten(payload);
169
+ };
170
+ const onSubmit = async () => {
171
+ if (!isValid || !_case.case_id) {
172
+ return;
173
+ }
174
+ setSubmitting(true);
175
+ try {
176
+ let eventId = createdEventId;
177
+ if (!eventId) {
178
+ const ids = await dispatchApi(api.v2.ingest.post('event', [buildPayload()], 'wait_for'));
179
+ eventId = ids?.[0];
180
+ if (!eventId) {
181
+ throw new Error(t('modal.cases.add_event.error.no_id'));
182
+ }
183
+ setCreatedEventId(eventId);
184
+ }
185
+ const updatedCase = await dispatchApi(api.v2.case.items.post(_case.case_id, {
186
+ type: 'event',
187
+ value: eventId,
188
+ name: title.trim(),
189
+ parent: selectedParent
190
+ }));
191
+ if (updatedCase) {
192
+ onUpdated?.(updatedCase);
193
+ close();
194
+ }
195
+ }
196
+ finally {
197
+ setSubmitting(false);
198
+ }
199
+ };
200
+ const renderField = (field) => {
201
+ if (!field.key) {
202
+ return null;
203
+ }
204
+ return (_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "flex-start", children: [_jsx(Box, { flex: 1, children: _jsx(FieldInput, { field: field, value: fieldValues[field.key], onChange: value => setFieldValues(current => ({ ...current, [field.key]: value })) }) }), _jsx(IconButton, { "aria-label": t('modal.cases.add_event.remove_field'), onClick: () => removeOptionalField(field.key), children: _jsx(Delete, {}) })] }, field.key));
205
+ };
206
+ return (_jsxs(Stack, { spacing: 2, p: 2, sx: { minWidth: 'min(900px, 70vw)', maxHeight: '90vh', height: '100%' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.add_event') }), _jsxs(Stack, { spacing: 1, alignItems: "stretch", children: [_jsx(TextField, { required: true, size: "small", label: t('modal.cases.add_event.title'), value: title, onChange: event => setTitle(event.target.value), fullWidth: true }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsx(DateTimePicker, { value: created, onChange: setCreated, ampm: false, slotProps: {
207
+ textField: { required: true, size: 'small', fullWidth: true },
208
+ dialog: { disablePortal: true },
209
+ popper: { disablePortal: true }
210
+ }, label: t('modal.cases.add_event.created') }) }), _jsx(Autocomplete, { fullWidth: true, disablePortal: true, options: ESCALATIONS, value: escalation, onChange: (_, value) => setEscalation(value), renderInput: params => (_jsx(TextField, { ...params, required: true, size: "small", label: t('modal.cases.add_event.escalation') })) })] }), _jsx(TextField, { required: true, size: "small", label: t('modal.cases.add_event.provider'), value: provider, onChange: event => setProvider(event.target.value), fullWidth: true }), _jsx(TextField, { size: "small", label: t('modal.cases.add_event.summary'), value: summary, onChange: event => setSummary(event.target.value) }), folderOptions.length > 0 && (_jsx(Autocomplete, { disablePortal: true, options: folderOptions, getOptionLabel: option => option.label, value: folderOptions.find(folder => folder.id === selectedParent) ?? null, onChange: (_, value) => setSelectedParent(value?.id), renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('modal.cases.add_item.folder') }) })), _jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.add_event.outline_required') }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(TextField, { fullWidth: true, size: "small", label: t('modal.cases.add_event.target'), value: target, onChange: event => setTarget(event.target.value) }), _jsx(TextField, { fullWidth: true, size: "small", label: t('modal.cases.add_event.threat'), value: threat, onChange: event => setThreat(event.target.value) })] }), _jsx(TextField, { size: "small", multiline: true, label: t('modal.cases.add_event.indicators'), value: indicators, onChange: event => setIndicators(event.target.value), helperText: t('modal.cases.add_event.indicators_help') })] }), _jsx(Divider, { children: _jsx(Typography, { variant: "caption", children: t('modal.cases.add_event.additional_fields') }) }), _jsx(Autocomplete, { disablePortal: true, options: filteredFields, filterOptions: options => options, inputValue: fieldSearch, onInputChange: (_, value) => setFieldSearch(value), value: null, onChange: (_, field) => field && setOptionalField(field), getOptionLabel: field => field.key ?? '', isOptionEqualToValue: (option, value) => option.key === value.key, renderOption: (props, field) => (_createElement(Box, { component: "li", ...props, key: field.key },
211
+ _jsxs(Stack, { children: [_jsx(Typography, { variant: "body2", children: field.key }), _jsxs(Typography, { variant: "caption", color: "text.secondary", children: [field.type, " ", field.description ? `- ${field.description}` : ''] })] }))), renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('modal.cases.add_event.search_fields') }) }), selectedFields.length > 0 && (_jsx(Stack, { spacing: 1, overflow: "auto", flex: 1, pt: 1, children: selectedFields.map(renderField) })), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, disabled: submitting, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: !isValid || submitting, startIcon: submitting ? _jsx(CircularProgress, { size: 16, color: "inherit" }) : _jsx(Add, {}), onClick: onSubmit, children: t('modal.cases.add_event.submit') })] })] }));
212
+ };
213
+ export default AddEventModal;
@@ -0,0 +1,152 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import dayjs from 'dayjs';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import AddEventModal from './AddEventModal';
7
+ const mockDispatchApi = vi.hoisted(() => vi.fn());
8
+ const mockClose = vi.hoisted(() => vi.fn());
9
+ const mockIngestPost = vi.hoisted(() => vi.fn());
10
+ const mockItemsPost = vi.hoisted(() => vi.fn());
11
+ const mockFieldsGet = vi.hoisted(() => vi.fn());
12
+ vi.mock('components/hooks/useMyApi', () => ({
13
+ default: () => ({ dispatchApi: mockDispatchApi })
14
+ }));
15
+ vi.mock('react-i18next', () => ({
16
+ useTranslation: () => ({ t: (key) => key })
17
+ }));
18
+ vi.mock('@mui/x-date-pickers/LocalizationProvider', () => ({
19
+ LocalizationProvider: ({ children }) => children
20
+ }));
21
+ vi.mock('@mui/x-date-pickers/AdapterDayjs', () => ({ AdapterDayjs: class {
22
+ } }));
23
+ vi.mock('@mui/x-date-pickers/DateTimePicker', () => ({
24
+ DateTimePicker: ({ label, onChange }) => (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", onClick: () => onChange(dayjs('2026-09-21T12:00:00Z')), children: [label, " valid"] }), _jsxs("button", { type: "button", onClick: () => onChange(dayjs('not-a-date')), children: [label, " invalid"] })] }))
25
+ }));
26
+ vi.mock('api', () => ({
27
+ default: {
28
+ search: {
29
+ fields: {
30
+ event: {
31
+ get: () => mockFieldsGet()
32
+ }
33
+ }
34
+ },
35
+ v2: {
36
+ ingest: {
37
+ post: (...args) => mockIngestPost(...args)
38
+ },
39
+ case: {
40
+ items: {
41
+ post: (...args) => mockItemsPost(...args)
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }));
47
+ vi.mock('components/app/providers/ModalProvider', async () => {
48
+ const { createContext } = await import('react');
49
+ return {
50
+ ModalContext: createContext({ close: mockClose })
51
+ };
52
+ });
53
+ const testCase = {
54
+ __index: 'case',
55
+ case_id: 'case-1',
56
+ classification: 'UNCLASSIFIED',
57
+ items: []
58
+ };
59
+ const fields = [
60
+ {
61
+ key: 'destination.ip',
62
+ default: false,
63
+ indexed: true,
64
+ list: false,
65
+ stored: true,
66
+ type: 'ip',
67
+ description: 'IP address of the destination'
68
+ }
69
+ ];
70
+ const renderModal = () => render(_jsx(AddEventModal, { case: testCase }), {
71
+ wrapper: ({ children }) => children
72
+ });
73
+ describe('AddEventModal', () => {
74
+ beforeEach(() => {
75
+ vi.clearAllMocks();
76
+ });
77
+ it('requires escalation in addition to the other required fields', async () => {
78
+ mockFieldsGet.mockResolvedValue(fields);
79
+ mockDispatchApi.mockImplementation(async (request) => request);
80
+ renderModal();
81
+ await waitFor(() => expect(mockFieldsGet).toHaveBeenCalled());
82
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.title/), { target: { value: 'Manual event' } });
83
+ fireEvent.click(screen.getByRole('button', { name: 'modal.cases.add_event.created valid' }));
84
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.provider/), { target: { value: 'Analyst' } });
85
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.target/), { target: { value: 'host-1' } });
86
+ expect(screen.getByRole('button', { name: 'modal.cases.add_event.submit' })).toBeDisabled();
87
+ });
88
+ it('rejects an invalid created date', async () => {
89
+ const user = userEvent.setup();
90
+ mockFieldsGet.mockResolvedValue(fields);
91
+ mockDispatchApi.mockImplementation(async (request) => request);
92
+ renderModal();
93
+ await waitFor(() => expect(mockFieldsGet).toHaveBeenCalled());
94
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.title/), { target: { value: 'Manual event' } });
95
+ fireEvent.click(screen.getByRole('button', { name: 'modal.cases.add_event.created invalid' }));
96
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.provider/), { target: { value: 'Analyst' } });
97
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.target/), { target: { value: 'host-1' } });
98
+ await user.click(screen.getByRole('combobox', { name: /modal\.cases\.add_event\.escalation/ }));
99
+ await user.click(await screen.findByRole('option', { name: 'evidence' }));
100
+ expect(screen.getByRole('button', { name: 'modal.cases.add_event.submit' })).toBeDisabled();
101
+ });
102
+ it('fuzzy-searches optional fields and submits summary and escalation', async () => {
103
+ const user = userEvent.setup();
104
+ const updatedCase = { ...testCase, items: [{ type: 'event', value: 'event-1' }] };
105
+ mockFieldsGet.mockResolvedValue(fields);
106
+ mockIngestPost.mockReturnValue(Promise.resolve(['event-1']));
107
+ mockItemsPost.mockReturnValue(Promise.resolve(updatedCase));
108
+ mockDispatchApi.mockImplementation(async (request) => request);
109
+ renderModal();
110
+ await waitFor(() => expect(mockFieldsGet).toHaveBeenCalled());
111
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.title/), { target: { value: 'Manual event' } });
112
+ fireEvent.click(screen.getByRole('button', { name: 'modal.cases.add_event.created valid' }));
113
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.provider/), { target: { value: 'Analyst' } });
114
+ await user.click(screen.getByRole('combobox', { name: /modal\.cases\.add_event\.escalation/ }));
115
+ await user.click(await screen.findByRole('option', { name: 'evidence' }));
116
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.target/), { target: { value: 'host-1' } });
117
+ fireEvent.change(screen.getByLabelText(/modal\.cases\.add_event\.summary/), {
118
+ target: { value: 'Observed manually' }
119
+ });
120
+ const fieldSearch = screen.getByLabelText(/modal\.cases\.add_event\.search_fields/);
121
+ fireEvent.change(fieldSearch, { target: { value: 'destination IP' } });
122
+ await user.click(await screen.findByText('destination.ip'));
123
+ fireEvent.change(screen.getByLabelText(/destination\.ip/), { target: { value: '192.0.2.10' } });
124
+ await user.click(screen.getByRole('button', { name: 'modal.cases.add_event.submit' }));
125
+ expect(mockIngestPost).toHaveBeenCalledWith('event', [
126
+ expect.objectContaining({
127
+ classification: 'UNCLASSIFIED',
128
+ message: 'Manual event',
129
+ event: expect.objectContaining({
130
+ created: '2026-09-21T12:00:00.000Z',
131
+ kind: 'event',
132
+ provider: 'Analyst'
133
+ }),
134
+ howler: expect.objectContaining({
135
+ escalation: 'evidence',
136
+ outline: expect.objectContaining({
137
+ target: 'host-1',
138
+ summary: 'Observed manually'
139
+ })
140
+ }),
141
+ destination: expect.objectContaining({ ip: '192.0.2.10' })
142
+ })
143
+ ], 'wait_for');
144
+ expect(mockItemsPost).toHaveBeenCalledWith('case-1', {
145
+ type: 'event',
146
+ value: 'event-1',
147
+ name: 'Manual event',
148
+ parent: undefined
149
+ });
150
+ expect(mockClose).toHaveBeenCalled();
151
+ });
152
+ });
@@ -117,6 +117,7 @@
117
117
  "error.invalid_detection.message": "Invalid Detection",
118
118
  "event.module": "Event Module",
119
119
  "event.open": "Open Event",
120
+ "event.provider": "Event Provider",
120
121
  "event.type": "Event Type",
121
122
  "features.warning.description": "This feature is undergoing active development, and is not yet in a finished state. You may encounter bugs or instability.",
122
123
  "features.warning.title": "Feature In Active Development",
@@ -336,6 +337,23 @@
336
337
  "modal.action.empty": "Action Name cannot be empty.",
337
338
  "modal.action.label": "Action Name",
338
339
  "modal.action.title": "Save Action",
340
+ "modal.cases.add_event": "Add Event",
341
+ "modal.cases.add_event.additional_fields": "Additional Fields",
342
+ "modal.cases.add_event.created": "Event Created",
343
+ "modal.cases.add_event.error.no_id": "The event was created without an ID.",
344
+ "modal.cases.add_event.escalation": "Escalation",
345
+ "modal.cases.add_event.indicators": "Indicators",
346
+ "modal.cases.add_event.indicators_help": "Enter one indicator per line or separated by commas.",
347
+ "modal.cases.add_event.outline": "Event Outline",
348
+ "modal.cases.add_event.outline_required": "At least one target, threat, or indicator is required.",
349
+ "modal.cases.add_event.provider": "Source / Provenance",
350
+ "modal.cases.add_event.remove_field": "Remove field",
351
+ "modal.cases.add_event.search_fields": "Search field names and descriptions",
352
+ "modal.cases.add_event.submit": "Add Event",
353
+ "modal.cases.add_event.summary": "Summary",
354
+ "modal.cases.add_event.target": "Target",
355
+ "modal.cases.add_event.threat": "Threat",
356
+ "modal.cases.add_event.title": "Title",
339
357
  "modal.cases.add_item": "Add item",
340
358
  "modal.cases.add_item.content": "Content",
341
359
  "modal.cases.add_item.folder": "Folder",
@@ -462,6 +480,12 @@
462
480
  "page.cases.observables.type.user": "User",
463
481
  "page.cases.rules": "Rules",
464
482
  "page.cases.rules.author": "Author",
483
+ "page.cases.rules.backfill": "Backfill rule",
484
+ "page.cases.rules.backfill.confirm": "This will submit {{count}} matching alert(s) to correlation. Continue?",
485
+ "page.cases.rules.backfill.description": "Find existing alerts matching this rule and submit them to correlation.",
486
+ "page.cases.rules.backfill.preview": "Preview matches",
487
+ "page.cases.rules.backfill.since": "Alerts since",
488
+ "page.cases.rules.backfill.submit": "Submit to correlation",
465
489
  "page.cases.rules.create": "Create Rule",
466
490
  "page.cases.rules.delete.confirm": "Delete this rule?",
467
491
  "page.cases.rules.destination": "Alert Destination Path",
@@ -476,6 +500,7 @@
476
500
  "page.cases.rules.timeframe.days": "{{days}} day(s)",
477
501
  "page.cases.rules.timeframe.help": "Number of days before the rule expires",
478
502
  "page.cases.search": "Search",
503
+ "page.cases.sidebar.add_event": "Add event",
479
504
  "page.cases.sidebar.add_folder": "Add folder",
480
505
  "page.cases.sidebar.add_item": "Add item",
481
506
  "page.cases.sidebar.collapse_all": "Collapse all folders",
@@ -117,6 +117,7 @@
117
117
  "error.invalid_detection.message": "Détection non valide",
118
118
  "event.module": "Module d'événement",
119
119
  "event.open": "Ouvrir événement",
120
+ "event.provider": "Fournisseur de l'événement",
120
121
  "event.type": "Type d'événement",
121
122
  "features.warning.description": "Cette fonctionnalité fait l'objet d'un développement actif et n'est pas encore achevée. Il est possible que vous rencontriez des problèmes ou de l'instabilité.",
122
123
  "features.warning.title": "Fonctionnalité en développement actif",
@@ -336,6 +337,23 @@
336
337
  "modal.action.empty": "Le nom de l'action ne peut pas être vide.",
337
338
  "modal.action.label": "Nom de l'action",
338
339
  "modal.action.title": "Enregistrer l'action",
340
+ "modal.cases.add_event": "Ajouter un événement",
341
+ "modal.cases.add_event.additional_fields": "Champs supplémentaires",
342
+ "modal.cases.add_event.created": "Création de l'événement",
343
+ "modal.cases.add_event.error.no_id": "L'événement a été créé sans identifiant.",
344
+ "modal.cases.add_event.escalation": "Escalade",
345
+ "modal.cases.add_event.indicators": "Indicateurs",
346
+ "modal.cases.add_event.indicators_help": "Saisissez un indicateur par ligne ou séparé par des virgules.",
347
+ "modal.cases.add_event.outline": "Résumé de l'événement",
348
+ "modal.cases.add_event.outline_required": "Au moins une cible, une menace ou un indicateur est requis.",
349
+ "modal.cases.add_event.provider": "Source / provenance",
350
+ "modal.cases.add_event.remove_field": "Supprimer le champ",
351
+ "modal.cases.add_event.search_fields": "Rechercher dans les noms et descriptions de champs",
352
+ "modal.cases.add_event.submit": "Ajouter l'événement",
353
+ "modal.cases.add_event.summary": "Résumé",
354
+ "modal.cases.add_event.target": "Cible",
355
+ "modal.cases.add_event.threat": "Menace",
356
+ "modal.cases.add_event.title": "Titre",
339
357
  "modal.cases.add_item": "Ajouter un élément",
340
358
  "modal.cases.add_item.content": "Contenu",
341
359
  "modal.cases.add_item.folder": "Dossier",
@@ -462,6 +480,12 @@
462
480
  "page.cases.observables.type.user": "Utilisateur",
463
481
  "page.cases.rules": "Règles",
464
482
  "page.cases.rules.author": "Auteur",
483
+ "page.cases.rules.backfill": "Rattraper les alertes",
484
+ "page.cases.rules.backfill.confirm": "{{count}} alerte(s) correspondante(s) seront soumises à la corrélation. Continuer ?",
485
+ "page.cases.rules.backfill.description": "Rechercher les alertes existantes correspondant à cette règle et les soumettre à la corrélation.",
486
+ "page.cases.rules.backfill.preview": "Aperçu des correspondances",
487
+ "page.cases.rules.backfill.since": "Alertes depuis",
488
+ "page.cases.rules.backfill.submit": "Soumettre à la corrélation",
465
489
  "page.cases.rules.create": "Créer une règle",
466
490
  "page.cases.rules.delete.confirm": "Supprimer cette règle ?",
467
491
  "page.cases.rules.destination": "Chemin de destination des alertes",
@@ -476,6 +500,7 @@
476
500
  "page.cases.rules.timeframe.days": "{{days}} jour(s)",
477
501
  "page.cases.rules.timeframe.help": "Nombre de jours avant l'expiration de la règle",
478
502
  "page.cases.search": "Rechercher",
503
+ "page.cases.sidebar.add_event": "Ajouter un événement",
479
504
  "page.cases.sidebar.add_folder": "Ajouter un dossier",
480
505
  "page.cases.sidebar.add_item": "Ajouter un élément",
481
506
  "page.cases.sidebar.collapse_all": "Réduire tous les dossiers",
package/package.json CHANGED
@@ -97,7 +97,7 @@
97
97
  "url": "https://github.com/CybercentreCanada/howler"
98
98
  },
99
99
  "type": "module",
100
- "version": "3.1.0-dev.1705",
100
+ "version": "3.1.0-dev.1708",
101
101
  "exports": {
102
102
  "./i18n": "./i18n.js",
103
103
  "./index.css": "./index.css",
@@ -243,6 +243,10 @@
243
243
  "./api/v2/case": "./api/v2/case/index.js",
244
244
  "./api/v2/search/*": "./api/v2/search/*.js",
245
245
  "./api/v2/search": "./api/v2/search/index.js",
246
+ "./api/v2/case/rules/*": "./api/v2/case/rules/*.js",
247
+ "./api/v2/case/rules": "./api/v2/case/rules/index.js",
248
+ "./api/v2/case/rules/backfill/*": "./api/v2/case/rules/backfill/*.js",
249
+ "./api/v2/case/rules/backfill": "./api/v2/case/rules/backfill/index.js",
246
250
  "./api/search/histogram/*": "./api/search/histogram/*.js",
247
251
  "./api/search/histogram": "./api/search/histogram/index.js",
248
252
  "./api/search/count/*": "./api/search/count/*.js",