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 = {