-
Notifications
You must be signed in to change notification settings - Fork 71
Implmentation of contextual launch #1005
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
22ff3f7
d7c9bae
83b344f
dc5240a
cbaf3de
a098899
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| /* | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * | ||
| * The OpenSearch Contributors require contributions made to | ||
| * this file be licensed under the Apache-2.0 license or a | ||
| * compatible open source license. | ||
| * | ||
| * Modifications Copyright OpenSearch Contributors. See | ||
| * GitHub history for details. | ||
| */ | ||
|
|
||
| import React from 'react'; | ||
| import { render, screen, fireEvent, waitFor } from '@testing-library/react'; | ||
| import '@testing-library/jest-dom'; | ||
| import { AnomalyResultsTable } from '../containers/AnomalyResultsTable'; | ||
| import { getSavedObjectsClient, getNotifications, getDataSourceEnabled } from '../../../services'; | ||
| import { CoreServicesContext } from '../../../components/CoreServices/CoreServices'; | ||
|
|
||
| const mockWindowOpen = jest.fn(); | ||
| Object.defineProperty(window, 'open', { | ||
| value: mockWindowOpen, | ||
| writable: true, | ||
| }); | ||
|
|
||
| jest.mock('../../../services', () => ({ | ||
| getSavedObjectsClient: jest.fn(), | ||
| getNotifications: jest.fn(), | ||
| getDataSourceEnabled: jest.fn(), | ||
| })); | ||
|
|
||
| const mockCoreServices = { | ||
| uiSettings: { | ||
| get: jest.fn().mockReturnValue(false), | ||
| }, | ||
| }; | ||
|
|
||
| const renderWithContext = (component: React.ReactElement) => { | ||
| return render( | ||
| <CoreServicesContext.Provider value={mockCoreServices as any}> | ||
| {component} | ||
| </CoreServicesContext.Provider> | ||
| ); | ||
| }; | ||
|
|
||
| describe('AnomalyResultsTable', () => { | ||
| const mockAnomalies = [ | ||
| { | ||
| startTime: 1617235200000, | ||
| endTime: 1617238800000, | ||
| anomalyGrade: 0.8, | ||
| confidence: 0.9, | ||
| entity: [ | ||
| { name: 'DestCityName', value: 'Zurich' }, | ||
| { name: 'OriginCityName', value: 'Zurich' } | ||
| ], | ||
| }, | ||
| ]; | ||
|
|
||
| const defaultProps = { | ||
| anomalies: mockAnomalies, | ||
| detectorIndex: ['test-index'], | ||
| detectorTimeField: 'timestamp', | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
|
|
||
| (getSavedObjectsClient as jest.Mock).mockReturnValue({ | ||
| find: jest.fn().mockResolvedValue({ savedObjects: [] }), | ||
| create: jest.fn().mockResolvedValue({ id: 'test-id' }), | ||
| }); | ||
|
|
||
| (getNotifications as jest.Mock).mockReturnValue({ | ||
| toasts: { | ||
| addSuccess: jest.fn(), | ||
| addDanger: jest.fn(), | ||
| }, | ||
| }); | ||
|
|
||
| (getDataSourceEnabled as jest.Mock).mockReturnValue({ enabled: false }); | ||
| }); | ||
|
|
||
| it('shows no anomalies message when there are no anomalies', () => { | ||
| renderWithContext(<AnomalyResultsTable {...defaultProps} anomalies={[]} />); | ||
| expect(screen.getByText('There are no anomalies currently.')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders Actions column with discover icon', () => { | ||
| renderWithContext(<AnomalyResultsTable {...defaultProps} />); | ||
|
|
||
| const actionsColumn = screen.getByText('Actions'); | ||
| expect(actionsColumn).toBeInTheDocument(); | ||
|
|
||
| const discoverButton = screen.getByTestId('discoverIcon'); | ||
| expect(discoverButton).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('handles high cardinality detector with entity values', async () => { | ||
| const selectedHeatmapCell = { | ||
| entity: mockAnomalies[0].entity, | ||
| startTime: mockAnomalies[0].startTime, | ||
| endTime: mockAnomalies[0].endTime, | ||
| dateRange: { | ||
| startDate: mockAnomalies[0].startTime, | ||
| endDate: mockAnomalies[0].endTime, | ||
| }, | ||
| entityList: mockAnomalies[0].entity, | ||
| severity: 0.8, | ||
| }; | ||
|
|
||
| renderWithContext( | ||
| <AnomalyResultsTable | ||
| {...defaultProps} | ||
| isHCDetector={true} | ||
| selectedHeatmapCell={selectedHeatmapCell} | ||
| /> | ||
| ); | ||
|
|
||
| await waitFor(() => { | ||
| const table = screen.getByRole('table'); | ||
| expect(table).toBeInTheDocument(); | ||
|
|
||
| const cells = screen.getAllByRole('cell'); | ||
| const entityCell = cells.find(cell => | ||
| cell.textContent?.includes('DestCityName: Zurich') && | ||
| cell.textContent?.includes('OriginCityName: Zurich') | ||
| ); | ||
|
|
||
| expect(entityCell).toBeInTheDocument(); | ||
| expect(entityCell?.textContent).toContain('DestCityName: Zurich'); | ||
| expect(entityCell?.textContent).toContain('OriginCityName: Zurich'); | ||
| }); | ||
| }); | ||
|
|
||
| it('uses existing index pattern if found', async () => { | ||
| (getSavedObjectsClient as jest.Mock).mockReturnValue({ | ||
| find: jest.fn().mockResolvedValue({ | ||
| savedObjects: [{ id: 'existing-id' }] | ||
| }), | ||
| create: jest.fn(), | ||
| }); | ||
|
|
||
| const { container } = renderWithContext(<AnomalyResultsTable {...defaultProps} />); | ||
|
|
||
| const discoverButton = container.querySelector('[data-test-subj="discoverIcon"]'); | ||
| if (discoverButton) { | ||
| fireEvent.click(discoverButton); | ||
|
|
||
| const savedObjectsClient = getSavedObjectsClient(); | ||
| expect(savedObjectsClient.create).not.toHaveBeenCalled(); | ||
| } | ||
| }); | ||
|
|
||
| describe('mds feature flag', () => { | ||
| it('shows Actions column when mds is disabled', () => { | ||
| (getDataSourceEnabled as jest.Mock).mockReturnValue({ enabled: false }); | ||
|
|
||
| renderWithContext(<AnomalyResultsTable {...defaultProps} />); | ||
|
|
||
| const actionsColumn = screen.getByText('Actions'); | ||
| expect(actionsColumn).toBeInTheDocument(); | ||
|
|
||
| const discoverButton = screen.getByTestId('discoverIcon'); | ||
| expect(discoverButton).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('hides Actions column when mds is enabled', () => { | ||
| (getDataSourceEnabled as jest.Mock).mockReturnValue({ enabled: true }); | ||
|
|
||
| renderWithContext(<AnomalyResultsTable {...defaultProps} />); | ||
|
|
||
| const actionsColumn = screen.queryByText('Actions'); | ||
| expect(actionsColumn).not.toBeInTheDocument(); | ||
|
|
||
| const discoverButton = screen.queryByTestId('discoverIcon'); | ||
| expect(discoverButton).not.toBeInTheDocument(); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,8 +10,7 @@ | |
| */ | ||
|
|
||
| import { | ||
| //@ts-ignore | ||
| EuiBasicTable, | ||
| EuiBasicTable as EuiBasicTableComponent, | ||
| EuiEmptyPrompt, | ||
| EuiText, | ||
| } from '@elastic/eui'; | ||
|
|
@@ -29,12 +28,18 @@ import { AnomalyData } from '../../../models/interfaces'; | |
| import { getTitleWithCount } from '../../../utils/utils'; | ||
| import { convertToCategoryFieldAndEntityString } from '../../utils/anomalyResultUtils'; | ||
| import { HeatmapCell } from '../../AnomalyCharts/containers/AnomalyHeatmapChart'; | ||
| import { getSavedObjectsClient, getNotifications, getDataSourceEnabled } from '../../../services'; | ||
|
|
||
| //@ts-ignore | ||
| const EuiBasicTable = EuiBasicTableComponent as any; | ||
|
|
||
| interface AnomalyResultsTableProps { | ||
| anomalies: AnomalyData[]; | ||
| isHCDetector?: boolean; | ||
| isHistorical?: boolean; | ||
| selectedHeatmapCell?: HeatmapCell | undefined; | ||
| detectorIndices: string[]; | ||
| detectorTimeField: string; | ||
| } | ||
|
|
||
| interface ListState { | ||
|
|
@@ -62,6 +67,99 @@ export function AnomalyResultsTable(props: AnomalyResultsTableProps) { | |
| ? props.anomalies.filter((anomaly) => anomaly.anomalyGrade > 0) | ||
| : []; | ||
|
|
||
| const handleOpenDiscover = async (startTime: number, endTime: number, item: any) => { | ||
| try { | ||
| // calculate time range with 10-minute buffer on each side per customer request | ||
| const TEN_MINUTES_IN_MS = 10 * 60 * 1000; | ||
| const startISO = new Date(startTime - TEN_MINUTES_IN_MS).toISOString(); | ||
| const endISO = new Date(endTime + TEN_MINUTES_IN_MS).toISOString(); | ||
|
|
||
| const basePath = `${window.location.origin}${window.location.pathname.split('/app/')[0]}`; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. instead you could use the core service that is accessible by every plugin so like window.open(getUrlForApp(def.appId), '_blank', 'noopener, noreferrer')
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the suggestion, I didn't know we have such method :)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if you plan on mock the services to be super basic, you should be able to use the helper methods which should instantiate all the mocks for those services https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/core/public/mocks.ts#L200 for example, import { coreMock } from '../../../../../core/public/mocks';
const startMock = coreMock.createStart(); |
||
|
|
||
| const savedObjectsClient = getSavedObjectsClient(); | ||
|
|
||
| const indexPatternTitle = props.detectorIndices.join(','); | ||
|
|
||
| // try to find an existing index pattern with this title | ||
| const indexPatternResponse = await savedObjectsClient.find({ | ||
| type: 'index-pattern', | ||
| fields: ['title'], | ||
| search: `"${indexPatternTitle}"`, | ||
| searchFields: ['title'], | ||
| }); | ||
|
|
||
| let indexPatternId; | ||
|
|
||
| if (indexPatternResponse.savedObjects.length > 0) { | ||
| indexPatternId = indexPatternResponse.savedObjects[0].id; | ||
| } else { | ||
| // try to create a new index pattern | ||
| try { | ||
| const newIndexPattern = await savedObjectsClient.create('index-pattern', { | ||
| title: indexPatternTitle, | ||
| timeFieldName: props.detectorTimeField, | ||
| }); | ||
|
|
||
| indexPatternId = newIndexPattern.id; | ||
|
|
||
| getNotifications().toasts.addSuccess(`Created new index pattern: ${indexPatternTitle}`); | ||
jackiehanyang marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch (error) { | ||
| getNotifications().toasts.addDanger(`Failed to create index pattern: ${error.message}`); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // put query params for HC detector | ||
| let queryParams = ''; | ||
| if (props.isHCDetector && item[ENTITY_VALUE_FIELD]) { | ||
| const entityValues = item[ENTITY_VALUE_FIELD].split('\n').map((s: string) => s.trim()).filter(Boolean); | ||
| const filters = entityValues.map((entityValue: string) => { | ||
| const [field, value] = entityValue.split(': ').map((s: string) => s.trim()); | ||
| 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})))`; | ||
| }); | ||
|
|
||
| queryParams = `filters:!(${filters.join(',')}),`; | ||
| } | ||
|
|
||
| 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:''))`; | ||
jackiehanyang marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| window.open(discoverUrl, '_blank'); | ||
| } catch (error) { | ||
| getNotifications().toasts.addDanger('Error opening discover view'); | ||
jackiehanyang marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| }; | ||
|
|
||
| const getCustomColumns = () => { | ||
jackiehanyang marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const dataSourceEnabled = getDataSourceEnabled().enabled; | ||
| const columns = [...staticColumn] as any[]; | ||
|
|
||
| if (!dataSourceEnabled) { | ||
| const actionsColumnIndex = columns.findIndex((column: any) => column.field === 'actions'); | ||
|
|
||
| if (actionsColumnIndex !== -1) { | ||
| const actionsColumn = { ...columns[actionsColumnIndex] } as any; | ||
|
|
||
| if (actionsColumn.actions && Array.isArray(actionsColumn.actions)) { | ||
| actionsColumn.actions = [ | ||
| { | ||
| ...actionsColumn.actions[0], | ||
| onClick: (item: any) => handleOpenDiscover(item.startTime, item.endTime, item), | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| columns[actionsColumnIndex] = actionsColumn; | ||
| } | ||
| } else { | ||
| const actionsColumnIndex = columns.findIndex((column: any) => column.field === 'actions'); | ||
| if (actionsColumnIndex !== -1) { | ||
| columns.splice(actionsColumnIndex, 1); | ||
| } | ||
| } | ||
|
|
||
| return columns; | ||
| }; | ||
|
|
||
| const sortFieldCompare = (field: string, sortDirection: SORT_DIRECTION) => { | ||
| return (a: any, b: any) => { | ||
| if (get(a, `${field}`) > get(b, `${field}`)) | ||
|
|
@@ -134,6 +232,9 @@ export function AnomalyResultsTable(props: AnomalyResultsTableProps) { | |
| totalItemCount: Math.min(MAX_ANOMALIES, totalAnomalies.length), | ||
| pageSizeOptions: [10, 30, 50, 100], | ||
| }; | ||
|
|
||
| const customColumns = getCustomColumns(); | ||
|
|
||
| return ( | ||
| <ContentPanel | ||
| title={getTitleWithCount('Anomaly occurrences', totalAnomalies.length)} | ||
|
|
@@ -146,19 +247,19 @@ export function AnomalyResultsTable(props: AnomalyResultsTableProps) { | |
| columns={ | ||
| props.isHCDetector && props.isHistorical | ||
| ? [ | ||
| ...staticColumn.slice(0, 2), | ||
| ...customColumns.slice(0, 2), | ||
| entityValueColumn, | ||
| ...staticColumn.slice(3), | ||
| ...customColumns.slice(3), | ||
| ] | ||
| : props.isHCDetector | ||
| ? [ | ||
| ...staticColumn.slice(0, 2), | ||
| ...customColumns.slice(0, 2), | ||
| entityValueColumn, | ||
| ...staticColumn.slice(2), | ||
| ...customColumns.slice(2), | ||
| ] | ||
| : props.isHistorical | ||
| ? [...staticColumn.slice(0, 2), ...staticColumn.slice(3)] | ||
| : staticColumn | ||
| ? [...customColumns.slice(0, 2), ...customColumns.slice(3)] | ||
| : customColumns | ||
| } | ||
| onChange={handleTableChange} | ||
| sorting={sorting} | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.