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

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.
Files changed (36) hide show
  1. package/api/v2/fuzzy.d.ts +17 -0
  2. package/api/v2/fuzzy.js +11 -0
  3. package/api/v2/index.d.ts +2 -1
  4. package/api/v2/index.js +2 -1
  5. package/components/app/App.js +9 -4
  6. package/components/app/providers/ParameterProvider.d.ts +16 -1
  7. package/components/app/providers/ParameterProvider.js +23 -22
  8. package/components/app/providers/ParameterProvider.test.js +12 -0
  9. package/components/elements/addons/search/SearchTotal.js +1 -1
  10. package/components/elements/addons/search/SearchTotal.test.js +2 -2
  11. package/components/elements/search/FuzzySearchBar.d.ts +7 -0
  12. package/components/elements/search/FuzzySearchBar.js +33 -0
  13. package/components/elements/search/FuzzySearchBar.test.js +87 -0
  14. package/components/routes/cases/detail/{CaseAssets.d.ts → CaseObservables.d.ts} +2 -2
  15. package/components/routes/cases/detail/{CaseAssets.js → CaseObservables.js} +18 -18
  16. package/components/routes/cases/detail/{CaseAssets.test.js → CaseObservables.test.js} +34 -34
  17. package/components/routes/cases/detail/CaseSearch.d.ts +3 -0
  18. package/components/routes/cases/detail/CaseSearch.js +78 -0
  19. package/components/routes/cases/detail/CaseSidebar.js +2 -2
  20. package/components/routes/cases/detail/CaseSidebar.test.js +3 -3
  21. package/components/routes/cases/detail/observables/Observable.d.ts +14 -0
  22. package/components/routes/cases/detail/{assets/Asset.js → observables/Observable.js} +3 -3
  23. package/components/routes/cases/detail/observables/Observable.test.d.ts +1 -0
  24. package/components/routes/cases/detail/{assets/Asset.test.js → observables/Observable.test.js} +13 -13
  25. package/components/routes/cases/detail/sidebar/CaseFolder.test.js +1 -0
  26. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +3 -2
  27. package/components/routes/hits/search/shared/IndexPicker.d.ts +7 -1
  28. package/components/routes/hits/search/shared/IndexPicker.js +4 -3
  29. package/locales/en/translation.json +27 -18
  30. package/locales/fr/translation.json +27 -18
  31. package/models/entities/generated/Case.d.ts +1 -0
  32. package/package.json +3 -2
  33. package/tests/utils.js +1 -0
  34. package/components/routes/cases/detail/assets/Asset.d.ts +0 -14
  35. /package/components/{routes/cases/detail/CaseAssets.test.d.ts → elements/search/FuzzySearchBar.test.d.ts} +0 -0
  36. /package/components/routes/cases/detail/{assets/Asset.test.d.ts → CaseObservables.test.d.ts} +0 -0
@@ -6,28 +6,28 @@ import { createElement } from 'react';
6
6
  import { MemoryRouter } from 'react-router-dom';
7
7
  import { createMockEvent, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
8
8
  import { describe, expect, it, vi } from 'vitest';
9
- import { buildAssetEntries } from './CaseAssets';
9
+ import { buildObservableEntries } from './CaseObservables';
10
10
  // ---------------------------------------------------------------------------
11
11
  // Pure logic tests — no React needed
12
12
  // ---------------------------------------------------------------------------
13
- describe('buildAssetEntries', () => {
13
+ describe('buildObservableEntries', () => {
14
14
  it('returns an empty array for records with no related field', () => {
15
- expect(buildAssetEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
15
+ expect(buildObservableEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
16
16
  });
17
17
  it('extracts a single IP from a hit', () => {
18
- const result = buildAssetEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
18
+ const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
19
19
  expect(result).toHaveLength(1);
20
20
  expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
21
21
  });
22
22
  it('extracts multiple fields from a single record', () => {
23
- const result = buildAssetEntries([
23
+ const result = buildObservableEntries([
24
24
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
25
25
  ]);
26
26
  const types = result.map(a => a.type).sort();
27
27
  expect(types).toEqual(['ip', 'user']);
28
28
  });
29
29
  it('deduplicates the same asset value across multiple records', () => {
30
- const result = buildAssetEntries([
30
+ const result = buildObservableEntries([
31
31
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
32
32
  createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
33
33
  ]);
@@ -35,14 +35,14 @@ describe('buildAssetEntries', () => {
35
35
  expect(result[0].seenIn).toEqual(['h1', 'obs1']);
36
36
  });
37
37
  it('keeps distinct asset values as separate entries', () => {
38
- const result = buildAssetEntries([
38
+ const result = buildObservableEntries([
39
39
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
40
40
  createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
41
41
  ]);
42
42
  expect(result).toHaveLength(2);
43
43
  });
44
44
  it('does not duplicate seenIn ids when the same record appears twice for the same asset', () => {
45
- const result = buildAssetEntries([
45
+ const result = buildObservableEntries([
46
46
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
47
47
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
48
48
  ]);
@@ -50,10 +50,10 @@ describe('buildAssetEntries', () => {
50
50
  });
51
51
  it('skips records with no howler.id', () => {
52
52
  const noId = { related: { ip: ['1.2.3.4'] } };
53
- expect(buildAssetEntries([noId])).toEqual([]);
53
+ expect(buildObservableEntries([noId])).toEqual([]);
54
54
  });
55
55
  it('handles the scalar `id` field on Related', () => {
56
- const result = buildAssetEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
56
+ const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
57
57
  expect(result).toHaveLength(1);
58
58
  expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
59
59
  });
@@ -66,7 +66,7 @@ describe('buildAssetEntries', () => {
66
66
  uri: ['https://example.com'],
67
67
  signature: ['rule-X']
68
68
  };
69
- const result = buildAssetEntries([createMockHit({ howler: { id: 'h1' }, related })]);
69
+ const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
70
70
  const types = result.map(a => a.type).sort();
71
71
  expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
72
72
  });
@@ -91,65 +91,65 @@ const mockCase = {
91
91
  { type: 'event', value: 'obs-1' }
92
92
  ]
93
93
  };
94
- const Wrapper = ({ children }) => createElement(MemoryRouter, { initialEntries: ['/cases/case-001/assets'] }, children);
94
+ const Wrapper = ({ children }) => createElement(MemoryRouter, { initialEntries: ['/cases/case-001/observables'] }, children);
95
95
  // lazy import the component after mocks are set up
96
- const CaseAssets = (await import('./CaseAssets')).default;
97
- describe('CaseAssets component', () => {
96
+ const CaseObservables = (await import('./CaseObservables')).default;
97
+ describe('CaseObservables component', () => {
98
98
  beforeEach(() => {
99
99
  mockDispatchApi.mockClear();
100
100
  });
101
101
  it('renders skeletons while records are loading', () => {
102
102
  mockDispatchApi.mockReturnValue(new Promise(() => { })); // never resolves
103
- render(_jsx(CaseAssets, { case: mockCase }), { wrapper: Wrapper });
103
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
104
104
  // 6 skeleton cards
105
105
  const skeletons = document.querySelectorAll('.MuiSkeleton-root');
106
106
  expect(skeletons.length).toBeGreaterThan(0);
107
107
  });
108
- it('shows "No assets found" when records have no related data', async () => {
108
+ it('shows "No observables found" when records have no related data', async () => {
109
109
  mockDispatchApi.mockResolvedValue({
110
110
  items: [{ howler: { id: 'hit-1' } }, { howler: { id: 'obs-1' } }]
111
111
  });
112
- render(_jsx(CaseAssets, { case: mockCase }), { wrapper: Wrapper });
113
- await screen.findByText('page.cases.assets.empty');
112
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
113
+ await screen.findByText('page.cases.observables.empty');
114
114
  });
115
- it('renders asset cards for extracted assets', async () => {
115
+ it('renders observable cards for extracted observables', async () => {
116
116
  mockDispatchApi.mockResolvedValue({
117
117
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
118
118
  });
119
- render(_jsx(CaseAssets, { case: mockCase }), { wrapper: Wrapper });
119
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
120
120
  await screen.findByText('1.2.3.4');
121
121
  expect(screen.getByText('alice')).toBeTruthy();
122
122
  });
123
- it('renders a filter chip for each asset type present', async () => {
123
+ it('renders a filter chip for each observable type present', async () => {
124
124
  mockDispatchApi.mockResolvedValue({
125
125
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
126
126
  });
127
- render(_jsx(CaseAssets, { case: mockCase }), { wrapper: Wrapper });
128
- await screen.findAllByText('page.cases.assets.type.ip');
129
- expect(screen.getAllByText('page.cases.assets.type.ip')).toHaveLength(2);
130
- expect(screen.getAllByText('page.cases.assets.type.user')).toHaveLength(2);
127
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
128
+ await screen.findAllByText('page.cases.observables.type.ip');
129
+ expect(screen.getAllByText('page.cases.observables.type.ip')).toHaveLength(2);
130
+ expect(screen.getAllByText('page.cases.observables.type.user')).toHaveLength(2);
131
131
  });
132
- it('filters assets when a type chip is clicked', async () => {
132
+ it('filters observables when a type chip is clicked', async () => {
133
133
  mockDispatchApi.mockResolvedValue({
134
134
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
135
135
  });
136
- render(_jsx(CaseAssets, { case: mockCase }), { wrapper: Wrapper });
137
- // Wait for both assets to appear
136
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
137
+ // Wait for both observables to appear
138
138
  await screen.findByText('1.2.3.4');
139
139
  expect(screen.getByText('alice')).toBeTruthy();
140
140
  // Click the 'ip' filter chip
141
- await userEvent.click(screen.getByRole('button', { name: 'page.cases.assets.type.ip' }));
141
+ await userEvent.click(screen.getByRole('button', { name: 'page.cases.observables.type.ip' }));
142
142
  // Alice (user) should be filtered out
143
143
  expect(screen.queryByText('alice')).toBeNull();
144
144
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
145
145
  });
146
- it('restores all assets when an active filter chip is clicked again', async () => {
146
+ it('restores all observables when an active filter chip is clicked again', async () => {
147
147
  mockDispatchApi.mockResolvedValue({
148
148
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
149
149
  });
150
- render(_jsx(CaseAssets, { case: mockCase }), { wrapper: Wrapper });
150
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
151
151
  await screen.findByText('1.2.3.4');
152
- const ipChip = screen.getByRole('button', { name: 'page.cases.assets.type.ip' });
152
+ const ipChip = screen.getByRole('button', { name: 'page.cases.observables.type.ip' });
153
153
  await userEvent.click(ipChip);
154
154
  await userEvent.click(ipChip);
155
155
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
@@ -157,8 +157,8 @@ describe('CaseAssets component', () => {
157
157
  });
158
158
  it('renders nothing when the case has no hit/event items', async () => {
159
159
  const emptyCase = { case_id: 'case-002', items: [] };
160
- render(_jsx(CaseAssets, { case: emptyCase }), { wrapper: Wrapper });
161
- await screen.findByText('page.cases.assets.empty');
160
+ render(_jsx(CaseObservables, { case: emptyCase }), { wrapper: Wrapper });
161
+ await screen.findByText('page.cases.observables.empty');
162
162
  expect(mockDispatchApi).not.toHaveBeenCalled();
163
163
  });
164
164
  });
@@ -0,0 +1,3 @@
1
+ import type { FC } from 'react';
2
+ declare const CaseSearch: FC;
3
+ export default CaseSearch;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Skeleton, Stack, Typography } from '@mui/material';
3
+ import api from '@cccsaurora/howler-ui/api';
4
+ import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
5
+ import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
6
+ import SearchPagination from '@cccsaurora/howler-ui/components/elements/addons/search/SearchPagination';
7
+ import SearchTotal from '@cccsaurora/howler-ui/components/elements/addons/search/SearchTotal';
8
+ import CaseCard from '@cccsaurora/howler-ui/components/elements/case/CaseCard';
9
+ import EventCard from '@cccsaurora/howler-ui/components/elements/event/EventCard';
10
+ import HitCard from '@cccsaurora/howler-ui/components/elements/hit/HitCard';
11
+ import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
12
+ import FuzzySearchBar from '@cccsaurora/howler-ui/components/elements/search/FuzzySearchBar';
13
+ import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
14
+ import { useCallback, useEffect, useMemo, useState } from 'react';
15
+ import { useOutletContext } from 'react-router-dom';
16
+ import { useContextSelector } from 'use-context-selector';
17
+ import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
18
+ import { isCase, isEvent, isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
19
+ const CaseSearch = () => {
20
+ const parentCase = useOutletContext();
21
+ const indexes = useContextSelector(ParameterContext, ctx => ctx.indexes);
22
+ const query = useContextSelector(ParameterContext, ctx => ctx.query);
23
+ const [hitLayout] = useMyLocalStorageItem(StorageKey.HIT_LAYOUT, HitLayout.NORMAL);
24
+ const [loading, setLoading] = useState(false);
25
+ const [response, setResponse] = useState(null);
26
+ const [error, setError] = useState(null);
27
+ const [offset, setOffset] = useState(0);
28
+ const caseIds = useMemo(() => parentCase
29
+ ? [
30
+ parentCase.case_id,
31
+ ...(parentCase.items ?? []).filter(item => item.type === 'case').map(item => item.value)
32
+ ].filter((id) => !!id)
33
+ : [], [parentCase]);
34
+ const handleSearch = useCallback(async (_query, _indexes) => {
35
+ setLoading(true);
36
+ setError(null);
37
+ try {
38
+ // If no indexes specified, search across all types
39
+ const searchIndexes = _indexes.length > 0 ? _indexes : ['case', 'hit', 'event'];
40
+ // Add case_id to the filters to scope the search to this case and its sub-cases
41
+ const filters = parentCase?.case_id
42
+ ? [`case_id:(${caseIds.join(' OR ')}) OR howler.related:(${caseIds.join(' OR ')})`]
43
+ : [];
44
+ setResponse(await api.v2.fuzzy.post({
45
+ query: _query,
46
+ indexes: searchIndexes,
47
+ rows: 25,
48
+ filters,
49
+ offset
50
+ }));
51
+ }
52
+ catch (err) {
53
+ setError(err.message || 'An error occurred while searching.');
54
+ setResponse(null);
55
+ }
56
+ finally {
57
+ setLoading(false);
58
+ }
59
+ }, [caseIds, offset, parentCase?.case_id]);
60
+ useEffect(() => {
61
+ if (query) {
62
+ handleSearch(query, indexes);
63
+ }
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [indexes, offset]);
66
+ return (_jsx(PageCenter, { maxWidth: "lg", textAlign: "left", children: _jsxs(Stack, { spacing: 1, children: [_jsx(FuzzySearchBar, { onSearch: handleSearch, loading: loading }), response && (_jsxs(Stack, { direction: "row", alignItems: "center", sx: { pt: 1 }, children: [_jsx(SearchTotal, { total: response.total, pageLength: response.items.length, offset: response.offset, sx: theme => ({ color: theme.palette.text.secondary, fontSize: '0.9em', fontStyle: 'italic' }) }), _jsx(Box, { flex: 1 }), _jsx(SearchPagination, { total: response.total, limit: response.rows, offset: response.offset, onChange: nextOffset => setOffset(nextOffset) })] })), error && (_jsx(Typography, { color: "error", sx: { mb: 2 }, children: error })), loading ? (_jsx(_Fragment, { children: _jsx(Skeleton, { variant: "rounded", height: 430 }) })) : ((response?.items ?? []).map(item => {
67
+ if (isHit(item)) {
68
+ return _jsx(HitCard, { id: item.howler.id, layout: hitLayout }, item.howler.id);
69
+ }
70
+ else if (isEvent(item)) {
71
+ return _jsx(EventCard, { id: item.howler.id, event: item }, item.howler.id);
72
+ }
73
+ else if (isCase(item)) {
74
+ return _jsx(CaseCard, { caseId: item.case_id, case: item }, item.case_id);
75
+ }
76
+ }))] }) }));
77
+ };
78
+ export default CaseSearch;
@@ -1,6 +1,6 @@
1
1
  import { 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 { CalendarMonth, Circle, Dashboard, Dataset, Rule } from '@mui/icons-material';
3
+ import { CalendarMonth, Circle, Dashboard, Dataset, Rule, Search } from '@mui/icons-material';
4
4
  import { alpha, Box, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Typography, useTheme } from '@mui/material';
5
5
  import api from '@cccsaurora/howler-ui/api';
6
6
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
@@ -99,7 +99,7 @@ const CaseSidebar = ({ case: _case, update }) => {
99
99
  maxHeight: 'calc(100vh - 64px)',
100
100
  display: 'flex',
101
101
  flexDirection: 'column'
102
- }, 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 }))] })] }), _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('assets')), component: Link, to: `/cases/${_case?.case_id}/assets`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.assets') })] }), _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') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
102
+ }, 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 }))] })] }), _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') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
103
103
  position: 'relative',
104
104
  borderRight: `thin solid ${theme.palette.divider}`
105
105
  }, children: _jsxs(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: [_jsx(LinearProgress, { sx: { mb: 0.5, opacity: +loading } }), _jsxs(DndContext, { sensors: sensors, collisionDetection: pointerWithin, onDragStart: handleDragStart, onDragEnd: handleDragEnd, children: [_jsx(CaseFolder, { case: _case, onItemUpdated: update }), _jsx(RootDropZone, { caseId: _case.case_id }), _jsx(DragOverlay, { dropAnimation: null, children: activeDragData && (_jsx(FolderEntry, { caseId: null, path: "", indent: 0, label: activeDragData.label, itemType: activeDragData.type })) })] })] }) }))] }));
@@ -146,10 +146,10 @@ describe('CaseSidebar', () => {
146
146
  const link = screen.getByText('page.cases.dashboard').closest('a');
147
147
  expect(link).toHaveAttribute('href', '/cases/case-1');
148
148
  });
149
- it('renders an Assets nav link pointing to the case assets route', () => {
149
+ it('renders an Observables nav link pointing to the case observables route', () => {
150
150
  renderSidebar();
151
- const link = screen.getByText('page.cases.assets').closest('a');
152
- expect(link).toHaveAttribute('href', '/cases/case-1/assets');
151
+ const link = screen.getByText('page.cases.observables').closest('a');
152
+ expect(link).toHaveAttribute('href', '/cases/case-1/observables');
153
153
  });
154
154
  it('renders a Timeline nav link pointing to the case timeline route', () => {
155
155
  renderSidebar();
@@ -0,0 +1,14 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import type { FC } from 'react';
3
+ export type ObservableType = 'hash' | 'hosts' | 'ip' | 'user' | 'ids' | 'id' | 'uri' | 'signature';
4
+ export interface ObservableEntry {
5
+ type: ObservableType;
6
+ value: string;
7
+ /** IDs of the hits/events this observable was seen in */
8
+ seenIn: string[];
9
+ }
10
+ declare const Observable: FC<{
11
+ asset: ObservableEntry;
12
+ case: Case;
13
+ }>;
14
+ export default Observable;
@@ -2,11 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Card, CardContent, Chip, Stack, Typography } from '@mui/material';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Link } from 'react-router-dom';
5
- const Asset = ({ asset, case: _case }) => {
5
+ const Observable = ({ asset, case: _case }) => {
6
6
  const { t } = useTranslation();
7
- return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.assets.type.${asset.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: asset.value })] }), asset.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.assets.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: asset.seenIn.map(id => {
7
+ return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${asset.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: asset.value })] }), asset.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: asset.seenIn.map(id => {
8
8
  const entry = _case.items.find(item => item.value === id);
9
9
  return (_jsx(Chip, { clickable: true, size: "small", label: entry.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${entry.path}` }, id));
10
10
  }) })] }))] }) }) }));
11
11
  };
12
- export default Asset;
12
+ export default Observable;
@@ -4,46 +4,46 @@ import { render, screen } from '@testing-library/react';
4
4
  import { MemoryRouter } from 'react-router-dom';
5
5
  import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
6
6
  import { describe, expect, it } from 'vitest';
7
- import Asset, {} from './Asset';
8
- const makeAsset = (overrides = {}) => ({
7
+ import Observable, {} from './Observable';
8
+ const makeObservable = (overrides = {}) => ({
9
9
  type: 'ip',
10
10
  value: '192.168.1.1',
11
11
  seenIn: [],
12
12
  ...overrides
13
13
  });
14
- describe('Asset', () => {
14
+ describe('Observable', () => {
15
15
  describe('type chip', () => {
16
16
  it('renders the correct label for each type', () => {
17
17
  const cases = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
18
18
  for (const type of cases) {
19
- const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ type, value: 'x' }), case: createMockCase() }) }));
20
- expect(screen.getByText(`page.cases.assets.type.${type}`)).toBeTruthy();
19
+ const { unmount } = render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ type, value: 'x' }), case: createMockCase() }) }));
20
+ expect(screen.getByText(`page.cases.observables.type.${type}`)).toBeTruthy();
21
21
  unmount();
22
22
  }
23
23
  });
24
24
  });
25
25
  describe('value display', () => {
26
26
  it('renders the asset value', () => {
27
- render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ value: '10.0.0.1' }), case: createMockCase() }) }));
27
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ value: '10.0.0.1' }), case: createMockCase() }) }));
28
28
  expect(screen.getByText('10.0.0.1')).toBeTruthy();
29
29
  });
30
30
  it('renders long hash values without truncation', () => {
31
31
  const hash = 'a'.repeat(64);
32
- render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ type: 'hash', value: hash }), case: createMockCase() }) }));
32
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ type: 'hash', value: hash }), case: createMockCase() }) }));
33
33
  expect(screen.getByText(hash)).toBeTruthy();
34
34
  });
35
35
  });
36
36
  describe('seen-in chips', () => {
37
37
  it('renders nothing when seenIn is empty', () => {
38
- render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ seenIn: [] }), case: createMockCase() }) }));
39
- expect(screen.queryByText('page.cases.assets.seen_in')).toBeNull();
38
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: [] }), case: createMockCase() }) }));
39
+ expect(screen.queryByText('page.cases.observables.seen_in')).toBeNull();
40
40
  });
41
41
  it('renders "Seen in" label when seenIn has entries', () => {
42
42
  const _case = createMockCase({
43
43
  items: [{ path: 'alerts/test-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
44
44
  });
45
- render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ seenIn: ['hit-001'] }), case: _case }) }));
46
- expect(screen.getByText('page.cases.assets.seen_in')).toBeTruthy();
45
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
46
+ expect(screen.getByText('page.cases.observables.seen_in')).toBeTruthy();
47
47
  });
48
48
  it('renders a chip labelled with entry.path for each seenIn id', () => {
49
49
  const _case = createMockCase({
@@ -53,7 +53,7 @@ describe('Asset', () => {
53
53
  { path: 'alerts/other-analytic (hit-003)', type: 'hit', value: 'hit-003' }
54
54
  ]
55
55
  });
56
- render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
56
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
57
57
  expect(screen.getByText('alerts/my-analytic (hit-001)')).toBeTruthy();
58
58
  expect(screen.getByText('events/obs-002')).toBeTruthy();
59
59
  expect(screen.getByText('alerts/other-analytic (hit-003)')).toBeTruthy();
@@ -63,7 +63,7 @@ describe('Asset', () => {
63
63
  case_id: 'case-abc',
64
64
  items: [{ path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
65
65
  });
66
- render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ seenIn: ['hit-001'] }), case: _case }) }));
66
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { asset: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
67
67
  const link = screen.getByText('alerts/my-analytic (hit-001)').closest('a');
68
68
  expect(link).not.toBeNull();
69
69
  expect(link?.getAttribute('href')).toBe('/cases/case-abc/alerts/my-analytic (hit-001)');
@@ -66,6 +66,7 @@ import CaseFolder from './CaseFolder';
66
66
  // Fixtures
67
67
  // ---------------------------------------------------------------------------
68
68
  const makeCase = (id, items = []) => ({
69
+ __index: 'case',
69
70
  case_id: id,
70
71
  title: `Case ${id}`,
71
72
  items
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import { act } from 'react';
4
+ import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
4
5
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
6
  // ---------------------------------------------------------------------------
6
7
  // Mocks
@@ -51,7 +52,7 @@ import CaseFolderContextMenu, { collectAllLeaves, getOpenUrl } from './CaseFolde
51
52
  // ---------------------------------------------------------------------------
52
53
  // Fixtures
53
54
  // ---------------------------------------------------------------------------
54
- const mockCase = { case_id: 'case-1', title: 'Test Case', items: [] };
55
+ const mockCase = createMockCase({ case_id: 'case-1' });
55
56
  const hitLeaf = { type: 'hit', value: 'hit-123', path: 'folder/hit-item' };
56
57
  const referenceLeaf = { type: 'reference', value: 'https://example.com', path: 'folder/ref-item' };
57
58
  const eventLeaf = { type: 'event', value: 'obs-456', path: 'folder/event-item' };
@@ -255,7 +256,7 @@ describe('CaseFolderContextMenu', () => {
255
256
  });
256
257
  });
257
258
  it('does not call the API when case_id is missing', () => {
258
- renderMenu({ _case: { title: 'No ID' }, leaf: hitLeaf });
259
+ renderMenu({ _case: createMockCase({ case_id: undefined, title: 'No ID' }), leaf: hitLeaf });
259
260
  act(() => {
260
261
  fireEvent.click(screen.getByTestId('remove-item'));
261
262
  });
@@ -1,2 +1,8 @@
1
- declare const _default: import("react").NamedExoticComponent<{}>;
1
+ import type { SearchIndex } from '@cccsaurora/howler-ui/api/v2/search';
2
+ declare const _default: import("react").NamedExoticComponent<{
3
+ additionalOptions?: {
4
+ label: string;
5
+ value: SearchIndex;
6
+ }[];
7
+ }>;
2
8
  export default _default;
@@ -10,11 +10,12 @@ const FILTER_OPTIONS = [
10
10
  { label: 'hit.search.index.hit', value: 'hit' },
11
11
  { label: 'hit.search.index.event', value: 'event' }
12
12
  ];
13
- const IndexPicker = () => {
13
+ const IndexPicker = ({ additionalOptions = [] }) => {
14
14
  const { t } = useTranslation();
15
15
  const indexes = useContextSelector(ParameterContext, ctx => ctx.indexes);
16
16
  const setIndexes = useContextSelector(ParameterContext, ctx => ctx.setIndexes);
17
- const selectedOptions = FILTER_OPTIONS.filter(opt => indexes.includes(opt.value));
18
- return (_jsx(ChipPopper, { icon: _jsx(FilterList, { fontSize: "small" }), label: selectedOptions.map(opt => t(opt.label)).join(', '), minWidth: "225px", slotProps: { chip: { size: 'small' } }, children: _jsx(Autocomplete, { size: "small", multiple: true, options: FILTER_OPTIONS, value: selectedOptions, onChange: (_ev, values) => values.length > 0 && setIndexes(values.map(val => val.value)), isOptionEqualToValue: (opt, val) => opt.value === val.value, getOptionLabel: opt => t(opt.label), renderInput: params => _jsx(TextField, { ...params }) }) }));
17
+ const allOptions = [...FILTER_OPTIONS, ...additionalOptions];
18
+ const selectedOptions = allOptions.filter(opt => indexes.includes(opt.value));
19
+ return (_jsx(ChipPopper, { icon: _jsx(FilterList, { fontSize: "small" }), label: selectedOptions.map(opt => t(opt.label)).join(', '), minWidth: "225px", slotProps: { chip: { size: 'small' } }, children: _jsx(Autocomplete, { size: "small", multiple: true, options: allOptions, value: selectedOptions, onChange: (_ev, values) => values.length > 0 && setIndexes(values.map(val => val.value)), isOptionEqualToValue: (opt, val) => opt.value === val.value, getOptionLabel: opt => t(opt.label), renderInput: params => _jsx(TextField, { ...params }) }) }));
19
20
  };
20
21
  export default memo(IndexPicker);
@@ -116,6 +116,7 @@
116
116
  "edit": "Edit",
117
117
  "enabled": "Enabled",
118
118
  "event.module": "Event Module",
119
+ "event.open": "Open Event",
119
120
  "event.type": "Event Type",
120
121
  "features.warning.description": "This feature is undergoing active development, and is not yet in a finished state. You may encounter bugs or instability.",
121
122
  "features.warning.title": "Feature In Active Development",
@@ -187,8 +188,8 @@
187
188
  "hit.header.target": "Target",
188
189
  "hit.header.threat": "Threat",
189
190
  "hit.header.view.case": "View case {{id}}",
190
- "hit.header.view.hit": "View hit {{id}}",
191
191
  "hit.header.view.event": "View event {{id}}",
192
+ "hit.header.view.hit": "View hit {{id}}",
192
193
  "hit.header.votes": "Votes: ",
193
194
  "hit.howler.related": "{{count}} related records",
194
195
  "hit.label": "Labels",
@@ -231,9 +232,9 @@
231
232
  "hit.panel.view.layout": "Change View Panel",
232
233
  "hit.quicksearch": "Search by assignment, analytic, detection or status",
233
234
  "hit.related.tab.case": "Cases",
235
+ "hit.related.tab.event": "Events",
234
236
  "hit.related.tab.hit": "Hits",
235
237
  "hit.related.tab.links": "Links",
236
- "hit.related.tab.event": "Events",
237
238
  "hit.search.aggregate.button": "Create Summary",
238
239
  "hit.search.button": "Perform search",
239
240
  "hit.search.custom": "Custom Sort",
@@ -243,8 +244,9 @@
243
244
  "hit.search.filter.label": "Lookup Filters",
244
245
  "hit.search.filter.values": "Values",
245
246
  "hit.search.index": "Indexes",
246
- "hit.search.index.hit": "Hits",
247
+ "hit.search.index.case": "Cases",
247
248
  "hit.search.index.event": "Events",
249
+ "hit.search.index.hit": "Hits",
248
250
  "hit.search.invalid": "Invalid Query",
249
251
  "hit.search.keyboard": "Keyboard shortcuts",
250
252
  "hit.search.keyboard.no_shortcuts": "No shortcuts",
@@ -368,7 +370,6 @@
368
370
  "no.data": "No Data",
369
371
  "none": "None",
370
372
  "normal": "Normal Priority",
371
- "event.open": "Open Event",
372
373
  "on": "on",
373
374
  "open": "Open",
374
375
  "operations.add_label": "Add Label",
@@ -390,18 +391,18 @@
390
391
  "page.403.title": "403: Access Forbidden",
391
392
  "page.404.description": "The page you are looking for cannot be found...",
392
393
  "page.404.title": "404: Not found",
393
- "page.cases.assets": "Assets",
394
- "page.cases.assets.empty": "No assets found.",
395
- "page.cases.assets.filter_by_type": "Filter by type:",
396
- "page.cases.assets.seen_in": "Seen in",
397
- "page.cases.assets.type.hash": "Hash",
398
- "page.cases.assets.type.hosts": "Host",
399
- "page.cases.assets.type.id": "ID",
400
- "page.cases.assets.type.ids": "ID",
401
- "page.cases.assets.type.ip": "IP",
402
- "page.cases.assets.type.signature": "Signature",
403
- "page.cases.assets.type.uri": "URI",
404
- "page.cases.assets.type.user": "User",
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",
405
406
  "page.cases.created": "Created",
406
407
  "page.cases.dashboard": "Dashboard",
407
408
  "page.cases.dashboard.alerts": "Alerts",
@@ -411,11 +412,11 @@
411
412
  "page.cases.dashboard.target": "Targets",
412
413
  "page.cases.dashboard.tasks": "Tasks",
413
414
  "page.cases.dashboard.tasks.add": "Add Task",
415
+ "page.cases.dashboard.tasks.child.empty": "No tasks in this child case.",
414
416
  "page.cases.dashboard.tasks.child_cases": "Child Cases",
415
417
  "page.cases.dashboard.tasks.filter_cases": "Filter child cases",
416
- "page.cases.dashboard.tasks.show_child": "Show child case tasks",
417
418
  "page.cases.dashboard.tasks.hide_child": "Hide child case tasks",
418
- "page.cases.dashboard.tasks.child.empty": "No tasks in this child case.",
419
+ "page.cases.dashboard.tasks.show_child": "Show child case tasks",
419
420
  "page.cases.dashboard.threat": "Threats",
420
421
  "page.cases.detail.participants": "Participants",
421
422
  "page.cases.detail.properties": "Properties",
@@ -434,6 +435,7 @@
434
435
  "page.cases.rules.no_expiry": "No expiry",
435
436
  "page.cases.rules.query": "Match Query",
436
437
  "page.cases.rules.timeframe": "Rule Expiry",
438
+ "page.cases.search": "Search",
437
439
  "page.cases.sidebar.folder.remove": "Remove folder",
438
440
  "page.cases.sidebar.item.open": "Open item",
439
441
  "page.cases.sidebar.item.remove": "Remove item",
@@ -847,6 +849,13 @@
847
849
  "rule.interval.thirty.minutes": "Every thirty minutes",
848
850
  "rule.interval.three.hours": "Every three hours",
849
851
  "save": "Save",
852
+ "search.fuzzy.no_results": "No results found.",
853
+ "search.fuzzy.placeholder": "Search by IP, domain, hash, email, or keyword...",
854
+ "search.fuzzy.results.cases": "Cases",
855
+ "search.fuzzy.results.events": "Events",
856
+ "search.fuzzy.results.hits": "Hits",
857
+ "search.fuzzy.title": "Fuzzy Search",
858
+ "search.fuzzy.total_results": "results found",
850
859
  "search.layout.settings": "Edit search result layout",
851
860
  "search.open": "Open Search",
852
861
  "search.result.showing": "Showing {{offset}} to {{length}} of {{total}} results",