Skip to content

Commit b853bdf

Browse files
Implmentation of contextual launch (#1005)
1 parent f8a96d9 commit b853bdf

File tree

4 files changed

+341
-8
lines changed

4 files changed

+341
-8
lines changed
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*
8+
* Modifications Copyright OpenSearch Contributors. See
9+
* GitHub history for details.
10+
*/
11+
12+
import React from 'react';
13+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
14+
import '@testing-library/jest-dom';
15+
import { AnomalyResultsTable } from '../containers/AnomalyResultsTable';
16+
import { getSavedObjectsClient, getNotifications, getDataSourceEnabled } from '../../../services';
17+
import { CoreServicesContext } from '../../../components/CoreServices/CoreServices';
18+
19+
const mockWindowOpen = jest.fn();
20+
Object.defineProperty(window, 'open', {
21+
value: mockWindowOpen,
22+
writable: true,
23+
});
24+
25+
jest.mock('../../../services', () => ({
26+
getSavedObjectsClient: jest.fn(),
27+
getNotifications: jest.fn(),
28+
getDataSourceEnabled: jest.fn(),
29+
}));
30+
31+
const mockCoreServices = {
32+
uiSettings: {
33+
get: jest.fn().mockReturnValue(false),
34+
},
35+
};
36+
37+
const renderWithContext = (component: React.ReactElement) => {
38+
return render(
39+
<CoreServicesContext.Provider value={mockCoreServices as any}>
40+
{component}
41+
</CoreServicesContext.Provider>
42+
);
43+
};
44+
45+
describe('AnomalyResultsTable', () => {
46+
const mockAnomalies = [
47+
{
48+
startTime: 1617235200000,
49+
endTime: 1617238800000,
50+
anomalyGrade: 0.8,
51+
confidence: 0.9,
52+
entity: [
53+
{ name: 'DestCityName', value: 'Zurich' },
54+
{ name: 'OriginCityName', value: 'Zurich' }
55+
],
56+
},
57+
];
58+
59+
const defaultProps = {
60+
anomalies: mockAnomalies,
61+
detectorIndices: ['test-index', 'followCluster:test-index'],
62+
detectorTimeField: 'timestamp',
63+
};
64+
65+
beforeEach(() => {
66+
jest.clearAllMocks();
67+
68+
(getSavedObjectsClient as jest.Mock).mockReturnValue({
69+
find: jest.fn().mockResolvedValue({ savedObjects: [] }),
70+
create: jest.fn().mockResolvedValue({ id: 'test-id' }),
71+
});
72+
73+
(getNotifications as jest.Mock).mockReturnValue({
74+
toasts: {
75+
addSuccess: jest.fn(),
76+
addDanger: jest.fn(),
77+
},
78+
});
79+
80+
(getDataSourceEnabled as jest.Mock).mockReturnValue({ enabled: false });
81+
});
82+
83+
it('shows no anomalies message when there are no anomalies', () => {
84+
renderWithContext(<AnomalyResultsTable {...defaultProps} anomalies={[]} />);
85+
expect(screen.getByText('There are no anomalies currently.')).toBeInTheDocument();
86+
});
87+
88+
it('renders Actions column with discover icon', () => {
89+
renderWithContext(<AnomalyResultsTable {...defaultProps} />);
90+
91+
const actionsColumn = screen.getByText('Actions');
92+
expect(actionsColumn).toBeInTheDocument();
93+
94+
const discoverButton = screen.getByTestId('discoverIcon');
95+
expect(discoverButton).toBeInTheDocument();
96+
});
97+
98+
it('handles high cardinality detector with entity values', async () => {
99+
const selectedHeatmapCell = {
100+
entity: mockAnomalies[0].entity,
101+
startTime: mockAnomalies[0].startTime,
102+
endTime: mockAnomalies[0].endTime,
103+
dateRange: {
104+
startDate: mockAnomalies[0].startTime,
105+
endDate: mockAnomalies[0].endTime,
106+
},
107+
entityList: mockAnomalies[0].entity,
108+
severity: 0.8,
109+
};
110+
111+
renderWithContext(
112+
<AnomalyResultsTable
113+
{...defaultProps}
114+
isHCDetector={true}
115+
selectedHeatmapCell={selectedHeatmapCell}
116+
/>
117+
);
118+
119+
await waitFor(() => {
120+
const table = screen.getByRole('table');
121+
expect(table).toBeInTheDocument();
122+
123+
const cells = screen.getAllByRole('cell');
124+
const entityCell = cells.find(cell =>
125+
cell.textContent?.includes('DestCityName: Zurich') &&
126+
cell.textContent?.includes('OriginCityName: Zurich')
127+
);
128+
129+
expect(entityCell).toBeInTheDocument();
130+
expect(entityCell?.textContent).toContain('DestCityName: Zurich');
131+
expect(entityCell?.textContent).toContain('OriginCityName: Zurich');
132+
});
133+
});
134+
135+
it('uses existing index pattern if found', async () => {
136+
(getSavedObjectsClient as jest.Mock).mockReturnValue({
137+
find: jest.fn().mockResolvedValue({
138+
savedObjects: [{ id: 'existing-id' }]
139+
}),
140+
create: jest.fn(),
141+
});
142+
143+
const { container } = renderWithContext(<AnomalyResultsTable {...defaultProps} />);
144+
145+
const discoverButton = container.querySelector('[data-test-subj="discoverIcon"]');
146+
if (discoverButton) {
147+
fireEvent.click(discoverButton);
148+
149+
const savedObjectsClient = getSavedObjectsClient();
150+
expect(savedObjectsClient.create).not.toHaveBeenCalled();
151+
}
152+
});
153+
154+
it('creates new index pattern when none exists', async () => {
155+
const mockCreate = jest.fn().mockResolvedValue({ id: 'new-index-pattern-id' });
156+
(getSavedObjectsClient as jest.Mock).mockReturnValue({
157+
find: jest.fn().mockResolvedValue({ savedObjects: [] }),
158+
create: mockCreate,
159+
});
160+
161+
const { container } = renderWithContext(<AnomalyResultsTable {...defaultProps} />);
162+
163+
const discoverButton = container.querySelector('[data-test-subj="discoverIcon"]');
164+
if (discoverButton) {
165+
fireEvent.click(discoverButton);
166+
167+
await waitFor(() => {
168+
expect(mockCreate).toHaveBeenCalledWith('index-pattern', {
169+
title: 'test-index,followCluster:test-index',
170+
timeFieldName: 'timestamp',
171+
});
172+
});
173+
174+
const notifications = getNotifications();
175+
expect(notifications.toasts.addSuccess).toHaveBeenCalledWith(
176+
expect.stringContaining('Created new index pattern: test-index,followCluster:test-index')
177+
);
178+
}
179+
});
180+
181+
describe('mds feature flag', () => {
182+
it('shows Actions column when mds is disabled', () => {
183+
(getDataSourceEnabled as jest.Mock).mockReturnValue({ enabled: false });
184+
185+
renderWithContext(<AnomalyResultsTable {...defaultProps} />);
186+
187+
const actionsColumn = screen.getByText('Actions');
188+
expect(actionsColumn).toBeInTheDocument();
189+
190+
const discoverButton = screen.getByTestId('discoverIcon');
191+
expect(discoverButton).toBeInTheDocument();
192+
});
193+
194+
it('hides Actions column when mds is enabled', () => {
195+
(getDataSourceEnabled as jest.Mock).mockReturnValue({ enabled: true });
196+
197+
renderWithContext(<AnomalyResultsTable {...defaultProps} />);
198+
199+
const actionsColumn = screen.queryByText('Actions');
200+
expect(actionsColumn).not.toBeInTheDocument();
201+
202+
const discoverButton = screen.queryByTestId('discoverIcon');
203+
expect(discoverButton).not.toBeInTheDocument();
204+
});
205+
});
206+
});

public/pages/DetectorResults/containers/AnomalyHistory.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,8 @@ export const AnomalyHistory = (props: AnomalyHistoryProps) => {
990990
isHCDetector={isHCDetector}
991991
isHistorical={props.isHistorical}
992992
selectedHeatmapCell={selectedHeatmapCell}
993+
detectorIndices={props.detector.indices}
994+
detectorTimeField={props.detector.timeField}
993995
/>
994996
)}
995997
</EuiPanel>

public/pages/DetectorResults/containers/AnomalyResultsTable.tsx

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
*/
1111

1212
import {
13-
//@ts-ignore
14-
EuiBasicTable,
13+
EuiBasicTable as EuiBasicTableComponent,
1514
EuiEmptyPrompt,
1615
EuiText,
1716
} from '@elastic/eui';
@@ -29,12 +28,18 @@ import { AnomalyData } from '../../../models/interfaces';
2928
import { getTitleWithCount } from '../../../utils/utils';
3029
import { convertToCategoryFieldAndEntityString } from '../../utils/anomalyResultUtils';
3130
import { HeatmapCell } from '../../AnomalyCharts/containers/AnomalyHeatmapChart';
31+
import { getSavedObjectsClient, getNotifications, getDataSourceEnabled } from '../../../services';
32+
33+
//@ts-ignore
34+
const EuiBasicTable = EuiBasicTableComponent as any;
3235

3336
interface AnomalyResultsTableProps {
3437
anomalies: AnomalyData[];
3538
isHCDetector?: boolean;
3639
isHistorical?: boolean;
3740
selectedHeatmapCell?: HeatmapCell | undefined;
41+
detectorIndices: string[];
42+
detectorTimeField: string;
3843
}
3944

4045
interface ListState {
@@ -62,6 +67,99 @@ export function AnomalyResultsTable(props: AnomalyResultsTableProps) {
6267
? props.anomalies.filter((anomaly) => anomaly.anomalyGrade > 0)
6368
: [];
6469

70+
const handleOpenDiscover = async (startTime: number, endTime: number, item: any) => {
71+
try {
72+
// calculate time range with 10-minute buffer on each side per customer request
73+
const TEN_MINUTES_IN_MS = 10 * 60 * 1000;
74+
const startISO = new Date(startTime - TEN_MINUTES_IN_MS).toISOString();
75+
const endISO = new Date(endTime + TEN_MINUTES_IN_MS).toISOString();
76+
77+
const basePath = `${window.location.origin}${window.location.pathname.split('/app/')[0]}`;
78+
79+
const savedObjectsClient = getSavedObjectsClient();
80+
81+
const indexPatternTitle = props.detectorIndices.join(',');
82+
83+
// try to find an existing index pattern with this title
84+
const indexPatternResponse = await savedObjectsClient.find({
85+
type: 'index-pattern',
86+
fields: ['title'],
87+
search: `"${indexPatternTitle}"`,
88+
searchFields: ['title'],
89+
});
90+
91+
let indexPatternId;
92+
93+
if (indexPatternResponse.savedObjects.length > 0) {
94+
indexPatternId = indexPatternResponse.savedObjects[0].id;
95+
} else {
96+
// try to create a new index pattern
97+
try {
98+
const newIndexPattern = await savedObjectsClient.create('index-pattern', {
99+
title: indexPatternTitle,
100+
timeFieldName: props.detectorTimeField,
101+
});
102+
103+
indexPatternId = newIndexPattern.id;
104+
105+
getNotifications().toasts.addSuccess(`Created new index pattern: ${indexPatternTitle}`);
106+
} catch (error) {
107+
getNotifications().toasts.addDanger(`Failed to create index pattern: ${error.message}`);
108+
return;
109+
}
110+
}
111+
112+
// put query params for HC detector
113+
let queryParams = '';
114+
if (props.isHCDetector && item[ENTITY_VALUE_FIELD]) {
115+
const entityValues = item[ENTITY_VALUE_FIELD].split('\n').map((s: string) => s.trim()).filter(Boolean);
116+
const filters = entityValues.map((entityValue: string) => {
117+
const [field, value] = entityValue.split(': ').map((s: string) => s.trim());
118+
return `('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'${indexPatternId}',key:${field},negate:!f,params:(query:${value}),type:phrase),query:(match_phrase:(${field}:${value})))`;
119+
});
120+
121+
queryParams = `filters:!(${filters.join(',')}),`;
122+
}
123+
124+
const discoverUrl = `${basePath}/app/data-explorer/discover#?_a=(discover:(columns:!(_source),isDirty:!f,sort:!()),metadata:(indexPattern:'${indexPatternId}',view:discover))&_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:'${startISO}',to:'${endISO}'))&_q=(${queryParams}query:(language:kuery,query:''))`;
125+
126+
window.open(discoverUrl, '_blank');
127+
} catch (error) {
128+
getNotifications().toasts.addDanger('Error opening discover view');
129+
}
130+
};
131+
132+
const getCustomColumns = () => {
133+
const dataSourceEnabled = getDataSourceEnabled().enabled;
134+
const columns = [...staticColumn] as any[];
135+
136+
if (!dataSourceEnabled) {
137+
const actionsColumnIndex = columns.findIndex((column: any) => column.field === 'actions');
138+
139+
if (actionsColumnIndex !== -1) {
140+
const actionsColumn = { ...columns[actionsColumnIndex] } as any;
141+
142+
if (actionsColumn.actions && Array.isArray(actionsColumn.actions)) {
143+
actionsColumn.actions = [
144+
{
145+
...actionsColumn.actions[0],
146+
onClick: (item: any) => handleOpenDiscover(item.startTime, item.endTime, item),
147+
},
148+
];
149+
}
150+
151+
columns[actionsColumnIndex] = actionsColumn;
152+
}
153+
} else {
154+
const actionsColumnIndex = columns.findIndex((column: any) => column.field === 'actions');
155+
if (actionsColumnIndex !== -1) {
156+
columns.splice(actionsColumnIndex, 1);
157+
}
158+
}
159+
160+
return columns;
161+
};
162+
65163
const sortFieldCompare = (field: string, sortDirection: SORT_DIRECTION) => {
66164
return (a: any, b: any) => {
67165
if (get(a, `${field}`) > get(b, `${field}`))
@@ -134,6 +232,9 @@ export function AnomalyResultsTable(props: AnomalyResultsTableProps) {
134232
totalItemCount: Math.min(MAX_ANOMALIES, totalAnomalies.length),
135233
pageSizeOptions: [10, 30, 50, 100],
136234
};
235+
236+
const customColumns = getCustomColumns();
237+
137238
return (
138239
<ContentPanel
139240
title={getTitleWithCount('Anomaly occurrences', totalAnomalies.length)}
@@ -146,19 +247,19 @@ export function AnomalyResultsTable(props: AnomalyResultsTableProps) {
146247
columns={
147248
props.isHCDetector && props.isHistorical
148249
? [
149-
...staticColumn.slice(0, 2),
250+
...customColumns.slice(0, 2),
150251
entityValueColumn,
151-
...staticColumn.slice(3),
252+
...customColumns.slice(3),
152253
]
153254
: props.isHCDetector
154255
? [
155-
...staticColumn.slice(0, 2),
256+
...customColumns.slice(0, 2),
156257
entityValueColumn,
157-
...staticColumn.slice(2),
258+
...customColumns.slice(2),
158259
]
159260
: props.isHistorical
160-
? [...staticColumn.slice(0, 2), ...staticColumn.slice(3)]
161-
: staticColumn
261+
? [...customColumns.slice(0, 2), ...customColumns.slice(3)]
262+
: customColumns
162263
}
163264
onChange={handleTableChange}
164265
sorting={sorting}

0 commit comments

Comments
 (0)