@cccsaurora/howler-ui 2.19.0-cases.1097 → 2.19.0-cases.1111

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.
@@ -28,8 +28,8 @@ import AnalyticDetails from '@cccsaurora/howler-ui/components/routes/analytics/A
28
28
  import AnalyticSearch from '@cccsaurora/howler-ui/components/routes/analytics/AnalyticSearch';
29
29
  import CaseViewer from '@cccsaurora/howler-ui/components/routes/cases/CaseViewer';
30
30
  import Cases from '@cccsaurora/howler-ui/components/routes/cases/Cases';
31
- import CaseAssets from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseAssets';
32
31
  import CaseDashboard from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseDashboard';
32
+ import CaseObservables from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseObservables';
33
33
  import CaseRules from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseRules';
34
34
  import CaseTimeline from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseTimeline';
35
35
  import ItemPage from '@cccsaurora/howler-ui/components/routes/cases/detail/ItemPage';
@@ -214,8 +214,8 @@ const createRouter = () => createBrowserRouter([
214
214
  element: _jsx(CaseDashboard, {})
215
215
  },
216
216
  {
217
- path: 'assets',
218
- element: _jsx(CaseAssets, {})
217
+ path: 'observables',
218
+ element: _jsx(CaseObservables, {})
219
219
  },
220
220
  {
221
221
  path: 'timeline',
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Check, FormatListBulleted, HourglassBottom, Pause, People, WarningRounded } from '@mui/icons-material';
3
- import { Autocomplete, AvatarGroup, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
2
+ import { Check, FormatListBulleted, HourglassBottom, Pause, People, TrendingUp, WarningRounded } from '@mui/icons-material';
3
+ import { Autocomplete, AvatarGroup, Card, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
4
4
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
5
5
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
6
  import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
@@ -10,6 +10,7 @@ import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
10
10
  import dayjs from 'dayjs';
11
11
  import { useContext, useState } from 'react';
12
12
  import { useTranslation } from 'react-i18next';
13
+ import { ESCALATION_COLOR_MAP } from '../constants';
13
14
  import useCase from '../hooks/useCase';
14
15
  import ResolveModal from '../modals/ResolveModal';
15
16
  import SourceAggregate from './aggregates/SourceAggregate';
@@ -61,10 +62,18 @@ const CaseDetails = ({ case: providedCase }) => {
61
62
  'in-progress': _jsx(HourglassBottom, { color: "warning" }),
62
63
  'on-hold': _jsx(Pause, { color: "disabled" }),
63
64
  resolved: _jsx(Check, { color: "success" })
64
- }[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => handleStatus(status) })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [_jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading }), _jsx("div", { style: { flex: 1 } })] }), caseViewers.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(SocketBadge, { size: "medium" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.viewers') })] }), _jsx(AvatarGroup, { max: 4, sx: { alignSelf: 'start' }, componentsProps: {
65
+ }[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, disableClearable: true, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => {
66
+ if (status) {
67
+ handleStatus(status);
68
+ }
69
+ } }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(TrendingUp, { color: ESCALATION_COLOR_MAP[_case.escalation] }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.escalation') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, disableClearable: true, value: _case.escalation ?? null, options: config.lookups['case.escalation'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, escalation) => {
70
+ if (escalation) {
71
+ wrappedUpdate({ escalation });
72
+ }
73
+ } })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [_jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading }), _jsx("div", { style: { flex: 1 } })] }), caseViewers.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(SocketBadge, { size: "medium" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.viewers') })] }), _jsx(AvatarGroup, { max: 4, sx: { alignSelf: 'start' }, componentsProps: {
65
74
  additionalAvatar: {
66
75
  sx: { height: 32, width: 32, fontSize: '12px' }
67
76
  }
68
- }, children: caseViewers.map(viewer => (_jsx(HowlerAvatar, { userId: viewer, sx: { height: 32, width: 32 } }, viewer))) })] }))] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(FormatListBulleted, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.properties') })] }), _jsx(Table, { sx: { '& td': { p: 1 } }, children: _jsxs(TableBody, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.escalation') }) }), _jsx(TableCell, { children: _jsx(Chip, { size: "small", label: _case.escalation }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.created') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.created).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.updated') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.updated).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.sources') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: _jsx(SourceAggregate, { case: _case }) }) })] })] }) })] })] })] }));
77
+ }, children: caseViewers.map(viewer => (_jsx(HowlerAvatar, { userId: viewer, sx: { height: 32, width: 32 } }, viewer))) })] }))] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(FormatListBulleted, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.properties') })] }), _jsx(Table, { sx: { '& td': { p: 1 } }, children: _jsxs(TableBody, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.created') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.created).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.updated') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.updated).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.sources') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: _jsx(SourceAggregate, { case: _case }) }) })] })] }) })] })] })] }));
69
78
  };
70
79
  export default CaseDetails;
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent, {} from '@testing-library/user-event';
5
+ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
6
+ import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
7
+ import {} from 'react';
8
+ import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
11
+ // ---------------------------------------------------------------------------
12
+ // Hoisted stubs
13
+ // ---------------------------------------------------------------------------
14
+ const mockUpdate = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
15
+ const mockShowModal = vi.hoisted(() => vi.fn());
16
+ // ---------------------------------------------------------------------------
17
+ // Module mocks (registered before the dynamic import below)
18
+ // ---------------------------------------------------------------------------
19
+ vi.mock('../hooks/useCase', () => ({
20
+ default: ({ case: c }) => ({
21
+ case: c,
22
+ update: mockUpdate,
23
+ loading: false,
24
+ missing: false
25
+ })
26
+ }));
27
+ vi.mock('components/app/providers/ModalProvider', async () => {
28
+ const { createContext } = await import('react');
29
+ return {
30
+ ModalContext: createContext({
31
+ showModal: mockShowModal,
32
+ close: vi.fn(),
33
+ setContent: vi.fn(),
34
+ withConfirmDeleteModal: vi.fn()
35
+ })
36
+ };
37
+ });
38
+ vi.mock('components/elements/UserList', () => ({
39
+ default: () => _jsx("div", { id: "user-list" })
40
+ }));
41
+ vi.mock('components/elements/display/HowlerAvatar', () => ({
42
+ default: ({ userId }) => _jsx("div", { children: userId })
43
+ }));
44
+ vi.mock('components/elements/display/icons/SocketBadge', () => ({
45
+ default: () => _jsx("span", {})
46
+ }));
47
+ vi.mock('./aggregates/SourceAggregate', () => ({
48
+ default: () => _jsx("span", {})
49
+ }));
50
+ vi.mock('../modals/ResolveModal', () => ({
51
+ default: () => null
52
+ }));
53
+ // ---------------------------------------------------------------------------
54
+ // Shared provider config
55
+ // ---------------------------------------------------------------------------
56
+ const mockConfig = {
57
+ lookups: {
58
+ 'howler.status': ['open', 'in-progress', 'on-hold', 'resolved'],
59
+ 'case.escalation': ['normal', 'focus', 'crisis'],
60
+ 'howler.escalation': ['miss', 'hit', 'alert', 'evidence']
61
+ }
62
+ };
63
+ const mockViewers = {};
64
+ const Wrapper = ({ children }) => (_jsx(ApiConfigContext.Provider, { value: { config: mockConfig, setConfig: vi.fn() }, children: _jsx(SocketContext.Provider, { value: {
65
+ emit: vi.fn(),
66
+ open: true,
67
+ fetchViewers: vi.fn(),
68
+ addListener: vi.fn(),
69
+ removeListener: vi.fn(),
70
+ status: 1,
71
+ reconnect: vi.fn(),
72
+ viewers: { ...mockViewers }
73
+ }, children: children }) }));
74
+ // ---------------------------------------------------------------------------
75
+ // Import after mocks
76
+ // ---------------------------------------------------------------------------
77
+ const { default: CaseDetails } = await import('./CaseDetails');
78
+ // ---------------------------------------------------------------------------
79
+ // Tests
80
+ // ---------------------------------------------------------------------------
81
+ describe('CaseDetails', () => {
82
+ let user;
83
+ let testCase;
84
+ beforeEach(() => {
85
+ user = userEvent.setup();
86
+ vi.clearAllMocks();
87
+ mockUpdate.mockResolvedValue(undefined);
88
+ testCase = createMockCase({ case_id: 'test-case-id', status: 'open', escalation: 'normal' });
89
+ Object.keys(mockViewers).forEach(k => delete mockViewers[k]);
90
+ });
91
+ it('renders a skeleton and no controls when the case is null', () => {
92
+ render(_jsx(CaseDetails, { case: null }), { wrapper: Wrapper });
93
+ expect(document.querySelector('.MuiSkeleton-root')).toBeTruthy();
94
+ expect(screen.queryByRole('combobox')).toBeNull();
95
+ });
96
+ it('renders the status label and current status value', () => {
97
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
98
+ expect(screen.getByText('page.cases.detail.status')).toBeInTheDocument();
99
+ expect(screen.getByDisplayValue('open')).toBeInTheDocument();
100
+ });
101
+ it('renders the escalation label and current escalation value', () => {
102
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
103
+ expect(screen.getByText('page.cases.detail.escalation')).toBeInTheDocument();
104
+ expect(screen.getByDisplayValue('normal')).toBeInTheDocument();
105
+ });
106
+ describe('status changes', () => {
107
+ it('calls update with the new status when a non-resolved option is selected', async () => {
108
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
109
+ await user.click(screen.getByDisplayValue('open'));
110
+ await user.click(await screen.findByRole('option', { name: 'in-progress' }));
111
+ await waitFor(() => {
112
+ expect(mockUpdate).toHaveBeenCalledWith({ status: 'in-progress' });
113
+ });
114
+ });
115
+ it('opens the resolve modal instead of calling update when "resolved" is selected', async () => {
116
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
117
+ await user.click(screen.getByDisplayValue('open'));
118
+ await user.click(await screen.findByRole('option', { name: 'resolved' }));
119
+ expect(mockShowModal).toHaveBeenCalledOnce();
120
+ expect(mockUpdate).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+ describe('escalation changes', () => {
124
+ it('calls update with the new escalation when an option is selected', async () => {
125
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
126
+ await user.click(screen.getByDisplayValue('normal'));
127
+ await user.click(await screen.findByRole('option', { name: 'focus' }));
128
+ await waitFor(() => {
129
+ expect(mockUpdate).toHaveBeenCalledWith({ escalation: 'focus' });
130
+ });
131
+ });
132
+ });
133
+ describe('viewers section', () => {
134
+ it('hides the viewers section when no viewers are active', () => {
135
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
136
+ expect(screen.queryByText('page.cases.detail.viewers')).toBeNull();
137
+ });
138
+ it('shows the viewers section and renders viewer avatars when active viewers are present', () => {
139
+ mockViewers['test-case-id'] = ['user-1', 'user-2'];
140
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
141
+ expect(screen.getByText('page.cases.detail.viewers')).toBeInTheDocument();
142
+ expect(screen.getByText('user-1')).toBeInTheDocument();
143
+ expect(screen.getByText('user-2')).toBeInTheDocument();
144
+ });
145
+ });
146
+ });
@@ -1,9 +1,9 @@
1
1
  import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
2
  import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
3
3
  import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
4
- import { type AssetEntry } from './assets/Asset';
4
+ import { type ObservableEntry } from './observables/Observable';
5
5
  /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
6
- export declare const buildAssetEntries: (records: Partial<Hit | Event>[]) => AssetEntry[];
6
+ export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
7
7
  declare const _default: import("react").NamedExoticComponent<{
8
8
  case?: Case;
9
9
  caseId?: string;
@@ -6,16 +6,16 @@ import { memo, useEffect, useMemo, useState } from 'react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { useOutletContext } from 'react-router-dom';
8
8
  import useCase from '../hooks/useCase';
9
- import Asset, {} from './assets/Asset';
10
- /** All Related fields that carry asset values */
11
- const ASSET_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
9
+ import Observable, {} from './observables/Observable';
10
+ /** All Related fields that carry observable values */
11
+ const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
12
12
  /** Extract (type, value, seenInId) triples from a record's related field */
13
- const extractAssets = (related, recordId) => {
13
+ const extractObservables = (related, recordId) => {
14
14
  if (!related) {
15
15
  return [];
16
16
  }
17
17
  const results = [];
18
- for (const field of ASSET_FIELDS) {
18
+ for (const field of OBSERVABLE_FIELDS) {
19
19
  const raw = related[field];
20
20
  if (!raw) {
21
21
  continue;
@@ -30,7 +30,7 @@ const extractAssets = (related, recordId) => {
30
30
  return results;
31
31
  };
32
32
  /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
33
- export const buildAssetEntries = (records) => {
33
+ export const buildObservableEntries = (records) => {
34
34
  const map = new Map();
35
35
  for (const record of records) {
36
36
  const related = record.related ?? record.related;
@@ -38,7 +38,7 @@ export const buildAssetEntries = (records) => {
38
38
  if (!recordId) {
39
39
  continue;
40
40
  }
41
- for (const { type, value, id } of extractAssets(related, recordId)) {
41
+ for (const { type, value, id } of extractObservables(related, recordId)) {
42
42
  const key = `${type}:${value}`;
43
43
  if (!map.has(key)) {
44
44
  map.set(key, { type, value, seenIn: [] });
@@ -51,8 +51,8 @@ export const buildAssetEntries = (records) => {
51
51
  }
52
52
  return Array.from(map.values());
53
53
  };
54
- const RELATED_FIELDS = ASSET_FIELDS.map(f => `related.${f}`).join(',');
55
- const CaseAssets = ({ case: providedCase, caseId }) => {
54
+ const RELATED_FIELDS = OBSERVABLE_FIELDS.map(f => `related.${f}`).join(',');
55
+ const CaseObservables = ({ case: providedCase, caseId }) => {
56
56
  const { t } = useTranslation();
57
57
  const { dispatchApi } = useMyApi();
58
58
  const routeCase = useOutletContext();
@@ -73,17 +73,17 @@ const CaseAssets = ({ case: providedCase, caseId }) => {
73
73
  fl: `howler.id,${RELATED_FIELDS}`
74
74
  })).then(response => setRecords(response.items));
75
75
  }, [dispatchApi, ids]);
76
- const allAssets = useMemo(() => (records ? buildAssetEntries(records) : []), [records]);
77
- const assetTypes = useMemo(() => (allAssets ? [...new Set(allAssets.map(a => a.type))].sort() : []), [allAssets]);
78
- const filteredAssets = useMemo(() => {
79
- if (allAssets.length < 1) {
76
+ const allObservables = useMemo(() => (records ? buildObservableEntries(records) : []), [records]);
77
+ const observableTypes = useMemo(() => (allObservables ? [...new Set(allObservables.map(a => a.type))].sort() : []), [allObservables]);
78
+ const filteredObservables = useMemo(() => {
79
+ if (allObservables.length < 1) {
80
80
  return [];
81
81
  }
82
82
  if (activeFilters.size === 0) {
83
- return allAssets;
83
+ return allObservables;
84
84
  }
85
- return allAssets.filter(a => activeFilters.has(a.type));
86
- }, [allAssets, activeFilters]);
85
+ return allObservables.filter(a => activeFilters.has(a.type));
86
+ }, [allObservables, activeFilters]);
87
87
  const toggleFilter = (type) => {
88
88
  setActiveFilters(prev => {
89
89
  const next = new Set(prev);
@@ -99,6 +99,6 @@ const CaseAssets = ({ case: providedCase, caseId }) => {
99
99
  if (!_case) {
100
100
  return null;
101
101
  }
102
- return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, flexWrap: "wrap", children: [_jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: t('page.cases.assets.filter_by_type') }), records === null ? (_jsx(Skeleton, { width: 240, height: 32 })) : (assetTypes.map(type => (_jsx(Chip, { label: t(`page.cases.assets.type.${type}`), size: "small", onClick: () => toggleFilter(type), color: activeFilters.has(type) ? 'primary' : 'default', variant: activeFilters.has(type) ? 'filled' : 'outlined' }, type))))] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, sm: 6, md: 4, xl: 3, children: _jsx(Skeleton, { height: 100 }) }, `skeleton-${i}`)))) : filteredAssets.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.assets.empty') }) })) : (filteredAssets.map(asset => (_jsx(Grid, { item: true, xs: 12, md: 6, xl: 4, children: _jsx(Asset, { asset: asset, case: _case }) }, `${asset.type}:${asset.value}`))))] }));
102
+ return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, flexWrap: "wrap", children: [_jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: t('page.cases.observables.filter_by_type') }), records === null ? (_jsx(Skeleton, { width: 240, height: 32 })) : (observableTypes.map(type => (_jsx(Chip, { label: t(`page.cases.observables.type.${type}`), size: "small", onClick: () => toggleFilter(type), color: activeFilters.has(type) ? 'primary' : 'default', variant: activeFilters.has(type) ? 'filled' : 'outlined' }, type))))] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, sm: 6, md: 4, xl: 3, children: _jsx(Skeleton, { height: 100 }) }, `skeleton-${i}`)))) : filteredObservables.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.observables.empty') }) })) : (filteredObservables.map(observable => (_jsx(Grid, { item: true, xs: 12, md: 6, xl: 4, children: _jsx(Observable, { asset: observable, case: _case }) }, `${observable.type}:${observable.value}`))))] }));
103
103
  };
104
- export default memo(CaseAssets);
104
+ export default memo(CaseObservables);
@@ -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
  });
@@ -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('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)');
@@ -390,18 +390,18 @@
390
390
  "page.403.title": "403: Access Forbidden",
391
391
  "page.404.description": "The page you are looking for cannot be found...",
392
392
  "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",
393
+ "page.cases.observables": "Observables",
394
+ "page.cases.observables.empty": "No observables found.",
395
+ "page.cases.observables.filter_by_type": "Filter by type:",
396
+ "page.cases.observables.seen_in": "Seen in",
397
+ "page.cases.observables.type.hash": "Hash",
398
+ "page.cases.observables.type.hosts": "Host",
399
+ "page.cases.observables.type.id": "ID",
400
+ "page.cases.observables.type.ids": "ID",
401
+ "page.cases.observables.type.ip": "IP",
402
+ "page.cases.observables.type.signature": "Signature",
403
+ "page.cases.observables.type.uri": "URI",
404
+ "page.cases.observables.type.user": "User",
405
405
  "page.cases.created": "Created",
406
406
  "page.cases.dashboard": "Dashboard",
407
407
  "page.cases.dashboard.alerts": "Alerts",
@@ -420,6 +420,7 @@
420
420
  "page.cases.detail.participants": "Participants",
421
421
  "page.cases.detail.properties": "Properties",
422
422
  "page.cases.detail.status": "Status",
423
+ "page.cases.detail.escalation": "Escalation",
423
424
  "page.cases.detail.viewers": "Active Viewers",
424
425
  "page.cases.escalation": "Escalation",
425
426
  "page.cases.folder.drop.root": "Place here to move to root",
@@ -390,18 +390,18 @@
390
390
  "page.403.title": "403: Accès interdit",
391
391
  "page.404.description": "La page que vous recherchez est introuvable ...",
392
392
  "page.404.title": "404: Introuvable",
393
- "page.cases.assets": "Actifs",
394
- "page.cases.assets.empty": "Aucun actif trouvé.",
395
- "page.cases.assets.filter_by_type": "Filtrer par type :",
396
- "page.cases.assets.seen_in": "Vu dans",
397
- "page.cases.assets.type.hash": "Hachage",
398
- "page.cases.assets.type.hosts": "Hôte",
399
- "page.cases.assets.type.id": "Identifiant",
400
- "page.cases.assets.type.ids": "Identifiant",
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": "Utilisateur",
393
+ "page.cases.observables": "Observables",
394
+ "page.cases.observables.empty": "Aucun observable trouvé.",
395
+ "page.cases.observables.filter_by_type": "Filtrer par type :",
396
+ "page.cases.observables.seen_in": "Vu dans",
397
+ "page.cases.observables.type.hash": "Hachage",
398
+ "page.cases.observables.type.hosts": "Hôte",
399
+ "page.cases.observables.type.id": "Identifiant",
400
+ "page.cases.observables.type.ids": "Identifiant",
401
+ "page.cases.observables.type.ip": "IP",
402
+ "page.cases.observables.type.signature": "Signature",
403
+ "page.cases.observables.type.uri": "URI",
404
+ "page.cases.observables.type.user": "Utilisateur",
405
405
  "page.cases.created": "Créé",
406
406
  "page.cases.dashboard": "Tableau de bord",
407
407
  "page.cases.dashboard.alerts": "Alertes",
@@ -420,6 +420,7 @@
420
420
  "page.cases.detail.participants": "Participants",
421
421
  "page.cases.detail.properties": "Propriétés",
422
422
  "page.cases.detail.status": "Statut",
423
+ "page.cases.detail.escalation": "Escalade",
423
424
  "page.cases.detail.viewers": "Spectateurs actifs",
424
425
  "page.cases.escalation": "Escalade",
425
426
  "page.cases.folder.drop.root": "Déposer ici pour déplacer à la racine",
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.19.0-cases.1097",
99
+ "version": "2.19.0-cases.1111",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",
@@ -212,7 +212,7 @@
212
212
  "./components/routes/cases/detail/*": "./components/routes/cases/detail/*.js",
213
213
  "./components/routes/cases/detail/aggregates/*": "./components/routes/cases/detail/aggregates/*.js",
214
214
  "./components/routes/cases/detail/sidebar/*": "./components/routes/cases/detail/sidebar/*.js",
215
- "./components/routes/cases/detail/assets/*": "./components/routes/cases/detail/assets/*.js",
215
+ "./components/routes/cases/detail/observables/*": "./components/routes/cases/detail/observables/*.js",
216
216
  "./components/app/providers/*": "./components/app/providers/*.js",
217
217
  "./components/app/drawers/*": "./components/app/drawers/*.js",
218
218
  "./components/app/hooks/*": "./components/app/hooks/*.js",
@@ -1,14 +0,0 @@
1
- import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
- import type { FC } from 'react';
3
- export type AssetType = 'hash' | 'hosts' | 'ip' | 'user' | 'ids' | 'id' | 'uri' | 'signature';
4
- export interface AssetEntry {
5
- type: AssetType;
6
- value: string;
7
- /** IDs of the hits/events this asset was seen in */
8
- seenIn: string[];
9
- }
10
- declare const Asset: FC<{
11
- asset: AssetEntry;
12
- case: Case;
13
- }>;
14
- export default Asset;