Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions public/pages/DetectorResults/__tests__/AnomalyResultsTable.test.tsx
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();
});
});
});
2 changes: 2 additions & 0 deletions public/pages/DetectorResults/containers/AnomalyHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,8 @@ export const AnomalyHistory = (props: AnomalyHistoryProps) => {
isHCDetector={isHCDetector}
isHistorical={props.isHistorical}
selectedHeatmapCell={selectedHeatmapCell}
detectorIndex={props.detector.indices}
detectorTimeField={props.detector.timeField}
/>
)}
</EuiPanel>
Expand Down
117 changes: 109 additions & 8 deletions public/pages/DetectorResults/containers/AnomalyResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
*/

import {
//@ts-ignore
EuiBasicTable,
EuiBasicTable as EuiBasicTableComponent,
EuiEmptyPrompt,
EuiText,
} from '@elastic/eui';
Expand All @@ -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 {
Expand Down Expand Up @@ -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]}`;
Copy link
Member

@kavilla kavilla Apr 4, 2025

Choose a reason for hiding this comment

The 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 getUrlForApp
https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/core/public/application/types.ts#L826

so like

window.open(getUrlForApp(def.appId), '_blank', 'noopener, noreferrer')

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, I didn't know we have such method :)
I would leave it as the current implementation because these two methods work exactly the same, but the one that uses the core service adds extra burden when writing unit tests, as it needs to be mocked.

Copy link
Member

Choose a reason for hiding this comment

The 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}`);
} 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}`))
Expand Down Expand Up @@ -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)}
Expand All @@ -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}
Expand Down
24 changes: 24 additions & 0 deletions public/pages/DetectorResults/utils/tableUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ export const staticColumn = [
truncateText: false,
dataType: 'number',
},
{
field: 'actions',
name: (
<EuiText size="xs" style={columnStyle}>
<b>Actions</b>{' '}
<EuiIconTip
content="This will create an index pattern with the indices used to create this detector (if it doesn't exist) and open the anomaly logs in Discover."
position="top"
type="iInCircle"
/>
</EuiText>
), align: 'left',
truncateText: true,
actions: [
{
type: 'icon',
name: 'View in Discover',
description: 'View in Discover',
icon: 'editorLink',
onClick: () => {},
'data-test-subj': 'discoverIcon',
},
],
}
] as EuiBasicTableColumn<any>[];

export const entityValueColumn = {
Expand Down
Loading