diff --git a/public/pages/DetectorResults/__tests__/AnomalyResultsTable.test.tsx b/public/pages/DetectorResults/__tests__/AnomalyResultsTable.test.tsx new file mode 100644 index 000000000..48502d6eb --- /dev/null +++ b/public/pages/DetectorResults/__tests__/AnomalyResultsTable.test.tsx @@ -0,0 +1,206 @@ +/* + * 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( + + {component} + + ); +}; + +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, + detectorIndices: ['test-index', 'followCluster: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(); + expect(screen.getByText('There are no anomalies currently.')).toBeInTheDocument(); + }); + + it('renders Actions column with discover icon', () => { + renderWithContext(); + + 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( + + ); + + 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(); + + const discoverButton = container.querySelector('[data-test-subj="discoverIcon"]'); + if (discoverButton) { + fireEvent.click(discoverButton); + + const savedObjectsClient = getSavedObjectsClient(); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + } + }); + + it('creates new index pattern when none exists', async () => { + const mockCreate = jest.fn().mockResolvedValue({ id: 'new-index-pattern-id' }); + (getSavedObjectsClient as jest.Mock).mockReturnValue({ + find: jest.fn().mockResolvedValue({ savedObjects: [] }), + create: mockCreate, + }); + + const { container } = renderWithContext(); + + const discoverButton = container.querySelector('[data-test-subj="discoverIcon"]'); + if (discoverButton) { + fireEvent.click(discoverButton); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith('index-pattern', { + title: 'test-index,followCluster:test-index', + timeFieldName: 'timestamp', + }); + }); + + const notifications = getNotifications(); + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith( + expect.stringContaining('Created new index pattern: test-index,followCluster:test-index') + ); + } + }); + + describe('mds feature flag', () => { + it('shows Actions column when mds is disabled', () => { + (getDataSourceEnabled as jest.Mock).mockReturnValue({ enabled: false }); + + renderWithContext(); + + 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(); + + const actionsColumn = screen.queryByText('Actions'); + expect(actionsColumn).not.toBeInTheDocument(); + + const discoverButton = screen.queryByTestId('discoverIcon'); + expect(discoverButton).not.toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/public/pages/DetectorResults/containers/AnomalyHistory.tsx b/public/pages/DetectorResults/containers/AnomalyHistory.tsx index 94e05ebd5..c9414084d 100644 --- a/public/pages/DetectorResults/containers/AnomalyHistory.tsx +++ b/public/pages/DetectorResults/containers/AnomalyHistory.tsx @@ -990,6 +990,8 @@ export const AnomalyHistory = (props: AnomalyHistoryProps) => { isHCDetector={isHCDetector} isHistorical={props.isHistorical} selectedHeatmapCell={selectedHeatmapCell} + detectorIndices={props.detector.indices} + detectorTimeField={props.detector.timeField} /> )} diff --git a/public/pages/DetectorResults/containers/AnomalyResultsTable.tsx b/public/pages/DetectorResults/containers/AnomalyResultsTable.tsx index cc4675fb2..929163ee5 100644 --- a/public/pages/DetectorResults/containers/AnomalyResultsTable.tsx +++ b/public/pages/DetectorResults/containers/AnomalyResultsTable.tsx @@ -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]}`; + + 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}`); + } 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:''))`; + + window.open(discoverUrl, '_blank'); + } catch (error) { + getNotifications().toasts.addDanger('Error opening discover view'); + } + }; + + const getCustomColumns = () => { + 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 ( + Actions{' '} + + + ), align: 'left', + truncateText: true, + actions: [ + { + type: 'icon', + name: 'View in Discover', + description: 'View in Discover', + icon: 'editorLink', + onClick: () => {}, + 'data-test-subj': 'discoverIcon', + }, + ], + } ] as EuiBasicTableColumn[]; export const entityValueColumn = {