diff --git a/src/platform/packages/shared/response-ops/rule_form/src/common/apis/create_rule/types.ts b/src/platform/packages/shared/response-ops/rule_form/src/common/apis/create_rule/types.ts index 996ea3cec4b72..8da57271ff7d2 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/common/apis/create_rule/types.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/common/apis/create_rule/types.ts @@ -22,4 +22,5 @@ export interface CreateRuleBody notifyWhen?: Rule['notifyWhen']; alertDelay?: Rule['alertDelay']; flapping?: Rule['flapping']; + artifacts?: Rule['artifacts']; } diff --git a/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/types.ts b/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/types.ts index eee0f46f46798..09378e0b1256b 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/types.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/types.ts @@ -19,4 +19,5 @@ export interface UpdateRuleBody notifyWhen?: Rule['notifyWhen']; alertDelay?: Rule['alertDelay']; flapping?: Rule['flapping']; + artifacts?: Rule['artifacts']; } diff --git a/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/update_rule.ts b/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/update_rule.ts index 677d6e1b13711..bbacc4496a0bb 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/update_rule.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/common/apis/update_rule/update_rule.ts @@ -23,6 +23,7 @@ export const UPDATE_FIELDS: Array = [ 'params', 'alertDelay', 'flapping', + 'artifacts', ]; export const UPDATE_FIELDS_WITH_ACTIONS: Array = [ @@ -33,6 +34,7 @@ export const UPDATE_FIELDS_WITH_ACTIONS: Array = [ 'alertDelay', 'actions', 'flapping', + 'artifacts', ]; export async function updateRule({ diff --git a/src/platform/packages/shared/response-ops/rule_form/src/common/index.ts b/src/platform/packages/shared/response-ops/rule_form/src/common/index.ts index 34d385a3f5d62..c8095bb78ca05 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/common/index.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/common/index.ts @@ -8,3 +8,4 @@ */ export * from './types'; +export * from './services/dashboard_service'; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/common/services/dashboard_service.test.ts b/src/platform/packages/shared/response-ops/rule_form/src/common/services/dashboard_service.test.ts new file mode 100644 index 0000000000000..5136742f40ab5 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/common/services/dashboard_service.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { dashboardServiceProvider } from './dashboard_service'; +import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; + +describe('DashboardService', () => { + const contentManagement = contentManagementMock.createStartContract(); + const dashboardService = dashboardServiceProvider(contentManagement); + + test('should fetch dashboards', async () => { + // arrange + const searchMock = jest.spyOn(contentManagement.client, 'search').mockResolvedValue({ + total: 0, + hits: [], + }); + + // act + const resp = await dashboardService.fetchDashboards({ text: 'test*' }); + + // assert + expect(searchMock).toHaveBeenCalledWith({ + contentTypeId: 'dashboard', + query: { + text: 'test*', + }, + options: { + fields: ['title', 'description'], + spaces: ['*'], + }, + }); + expect(resp).toEqual([]); + + searchMock.mockRestore(); + }); + + test('should fetch dashboard by id', async () => { + // mock get to resolve with a dashboard + const getMock = jest.spyOn(contentManagement.client, 'get').mockResolvedValue({ + item: { + error: null, + attributes: { + title: 'Dashboard 1', + }, + references: [], + }, + }); + + // act + const resp = await dashboardService.fetchDashboard('1'); + + // assert + expect(getMock).toHaveBeenCalledWith({ contentTypeId: 'dashboard', id: '1' }); + expect(resp).toEqual({ + status: 'success', + id: '1', + attributes: { + title: 'Dashboard 1', + }, + references: [], + }); + + getMock.mockRestore(); + }); + + test('should return an error if dashboard id is not found', async () => { + const getMock = jest.spyOn(contentManagement.client, 'get').mockRejectedValue({ + message: 'Dashboard not found', + }); + + const resp = await dashboardService.fetchDashboard('2'); + expect(getMock).toHaveBeenCalledWith({ contentTypeId: 'dashboard', id: '2' }); + expect(resp).toEqual({ + status: 'error', + id: '2', + error: 'Dashboard not found', + }); + }); +}); diff --git a/src/platform/packages/shared/response-ops/rule_form/src/common/services/dashboard_service.ts b/src/platform/packages/shared/response-ops/rule_form/src/common/services/dashboard_service.ts new file mode 100644 index 0000000000000..30d37a5d88743 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/common/services/dashboard_service.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SearchQuery } from '@kbn/content-management-plugin/common'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { Reference, ContentManagementCrudTypes } from '@kbn/content-management-utils'; +import type { SavedObjectError } from '@kbn/core/public'; +import type { GetIn } from '@kbn/content-management-plugin/common'; + +const DASHBOARD_CONTENT_TYPE_ID = 'dashboard'; +export type DashboardGetIn = GetIn; + +export type FindDashboardsByIdResponse = { id: string } & ( + | { status: 'success'; attributes: any; references: Reference[] } + | { status: 'error'; error: SavedObjectError } +); + +export interface DashboardItem { + id: string; + attributes: any; // DashboardAttributes is exported in the Dashboard plugin and this causes a cycle dependency. Get feedback on the best approach here +} + +export type DashboardService = ReturnType; +export type DashboardItems = Awaited>; + +export function dashboardServiceProvider(contentManagementService: ContentManagementPublicStart) { + return { + /** + * Fetch dashboards + * @param query - The query to search for dashboards + * @returns - The dashboards that match the query + */ + async fetchDashboards(query: SearchQuery = {}) { + const response = await contentManagementService.client.search({ + contentTypeId: 'dashboard', + query, + options: { spaces: ['*'], fields: ['title', 'description'] }, + }); + + // Assert the type of response to access hits property + return (response as { hits: DashboardItem[] }).hits; + }, + /** + * Fetch dashboard by id + * @param id - The id of the dashboard to fetch + * @returns - The dashboard with the given id + * @throws - An error if the dashboard does not exist + */ + async fetchDashboard(id: string): Promise { + try { + const response = await contentManagementService.client.get< + DashboardGetIn, + ContentManagementCrudTypes< + typeof DASHBOARD_CONTENT_TYPE_ID, + any, + object, + object, + object + >['GetOut'] + >({ + contentTypeId: 'dashboard', + id, + }); + if (response.item.error) { + throw response.item.error; + } + + return { + id, + status: 'success', + attributes: response.item.attributes, + references: response.item.references, + }; + } catch (error) { + return { + status: 'error', + error: error.body || error.message, + id, + }; + } + }, + + async fetchDashboardsByIds(ids: string[]) { + const findPromises = ids.map((id) => this.fetchDashboard(id)); + const results = await Promise.all(findPromises); + return results as FindDashboardsByIdResponse[]; + }, + /** + * Fetch only the dashboards that still exist + * @param ids - The ids of the dashboards to fetch + * @returns - The dashboards that exist + */ + async fetchValidDashboards(ids: string[]) { + const responses = await this.fetchDashboardsByIds(ids); + const existingDashboards = responses.filter(({ status }) => status === 'success'); + return existingDashboards; + }, + }; +} diff --git a/src/platform/packages/shared/response-ops/rule_form/src/create_rule_form.tsx b/src/platform/packages/shared/response-ops/rule_form/src/create_rule_form.tsx index bcde31a02d0ac..79aaca242cc8b 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/create_rule_form.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/create_rule_form.tsx @@ -138,6 +138,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { actions: newFormData.actions, alertDelay: newFormData.alertDelay, flapping: newFormData.flapping, + artifacts: newFormData.artifacts, }, }); }, diff --git a/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx b/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx index 4d072a3ae66a3..5e53930d40e05 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx @@ -112,6 +112,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { actions: newFormData.actions, alertDelay: newFormData.alertDelay, flapping: newFormData.flapping, + artifacts: newFormData.artifacts, }, }); }, diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.test.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.test.tsx new file mode 100644 index 0000000000000..72b4cc7bb9ca8 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { RuleDashboards } from './rule_dashboards'; + +const mockOnChange = jest.fn(); + +// Mock hooks +jest.mock('../hooks', () => ({ + useRuleFormState: jest.fn(), + useRuleFormDispatch: jest.fn(), +})); + +jest.mock('../common/services/dashboard_service', () => ({ + dashboardServiceProvider: jest.fn().mockReturnValue({ + fetchDashboard: jest.fn().mockImplementation(async (id: string) => { + return { + attributes: { title: `Dashboard ${id}` }, + status: 'success', + }; + }), + fetchDashboards: jest.fn().mockResolvedValue([ + { + id: '1', + attributes: { title: 'Dashboard 1' }, + status: 'success', + }, + { + id: '2', + attributes: { title: 'Dashboard 2' }, + status: 'success', + }, + ]), + }), +})); + +const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); +const { dashboardServiceProvider: mockDashboardServiceProvider } = jest.requireMock( + '../common/services/dashboard_service' +); + +describe('RuleDashboards', () => { + const contentManagement = contentManagementMock.createStartContract(); + beforeEach(() => { + useRuleFormDispatch.mockReturnValue(mockOnChange); + useRuleFormState.mockReturnValue({ + formData: { + params: { + dashboards: [], + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders linked dashboards combo box', async () => { + render( + + + + ); + + expect(screen.getByText('Related dashboards')).toBeInTheDocument(); + expect(useRuleFormState).toHaveBeenCalledTimes(1); + expect(useRuleFormDispatch).toHaveBeenCalledTimes(1); + expect(mockDashboardServiceProvider).toHaveBeenCalledTimes(1); + expect(mockDashboardServiceProvider().fetchDashboards).toHaveBeenCalledTimes(1); + expect(mockDashboardServiceProvider().fetchDashboard).not.toHaveBeenCalled(); + }); + + it('fetches and displays dashboard titles', async () => { + useRuleFormState.mockReturnValue({ + formData: { + artifacts: { + dashboards: [ + { + id: '1', + }, + ], + }, + }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Dashboard 1')).toBeInTheDocument(); + expect(mockDashboardServiceProvider().fetchDashboards).toHaveBeenCalled(); + expect(mockDashboardServiceProvider().fetchDashboards).toHaveBeenCalledTimes(1); + expect(mockDashboardServiceProvider().fetchDashboard).toHaveBeenCalled(); + }); + }); + + it('dispatches selected dashboards on change', async () => { + useRuleFormState.mockReturnValue({ + formData: { + artifacts: { + dashboards: [ + { + id: '1', + }, + ], + }, + }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Dashboard 1')).toBeInTheDocument(); + expect(screen.queryByText('Dashboard 2')).not.toBeInTheDocument(); + expect(mockDashboardServiceProvider().fetchDashboards).toHaveBeenCalled(); + expect(mockDashboardServiceProvider().fetchDashboard).toHaveBeenCalled(); + }); + + // Simulate selecting an option in the EuiComboBox + const inputWrap = screen + .getByTestId('ruleLinkedDashboards') + .querySelector('.euiComboBox__inputWrap'); + if (inputWrap) { + fireEvent.click(inputWrap); + } + fireEvent.click(screen.getByText('Dashboard 2')); + + expect(mockOnChange).toHaveBeenCalledWith({ + type: 'setRuleProperty', + payload: { + property: 'artifacts', + value: { dashboards: [{ id: '1' }, { id: '2' }] }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Dashboard 1')).toBeInTheDocument(); + expect(screen.getByText('Dashboard 2')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.tsx new file mode 100644 index 0000000000000..50ae368f8f556 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_dashboards.tsx @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import { OptionalFieldLabel } from '../optional_field_label'; +import { dashboardServiceProvider, type DashboardItem } from '../common/services/dashboard_service'; +import { useRuleFormState, useRuleFormDispatch } from '../hooks'; +import { ALERT_LINK_DASHBOARDS_TITLE, ALERT_LINK_DASHBOARDS_PLACEHOLDER } from '../translations'; + +export interface Props { + contentManagement: ContentManagementPublicStart; +} + +interface DashboardOption { + value: string; + label: string; +} + +export const RuleDashboards = ({ contentManagement }: Props) => { + const { formData } = useRuleFormState(); + const dispatch = useRuleFormDispatch(); + const dashboardsFormData = useMemo( + () => formData.artifacts?.dashboards ?? [], + [formData.artifacts] + ); + + const [dashboardList, setDashboardList] = useState(); + + const [selectedDashboards, setSelectedDashboards] = useState< + Array> | undefined + >(); + + const fetchDashboardTitles = useCallback(async () => { + if (!dashboardsFormData?.length || !contentManagement) { + return; + } + + try { + const dashboardPromises = dashboardsFormData.map(async (dashboard) => { + try { + const fetchedDashboard = await dashboardServiceProvider(contentManagement).fetchDashboard( + dashboard.id + ); + + // Only return the dashboard if it exists, fetch was successful, and has a title + if ( + fetchedDashboard && + fetchedDashboard.status === 'success' && + fetchedDashboard.attributes?.title + ) { + return { + label: fetchedDashboard.attributes.title, + value: dashboard.id, + }; + } + // Return null if dashboard doesn't have required data + return null; + } catch (dashboardError) { + /** + * Swallow the error that is thrown, since this just means the selected dashboard was deleted + * Return null when dashboard fetch fails + */ + return null; + } + }); + + const results = await Promise.all(dashboardPromises); + + // Filter out null results and cast to the expected type + const validDashboards = results.filter(Boolean) as Array>; + + setSelectedDashboards(validDashboards); + } catch (error) { + // Set empty array or handle the error appropriately + setSelectedDashboards([]); + } + }, [dashboardsFormData, contentManagement]); + + useMemo(() => { + fetchDashboardTitles(); + }, [fetchDashboardTitles]); + + const onChange = (selectedOptions: Array>) => { + const artifacts = { + ...formData.artifacts, + dashboards: selectedOptions.map((selectedOption) => ({ + id: selectedOption.value, + })), + }; + + setSelectedDashboards(selectedOptions); + dispatch({ + type: 'setRuleProperty', + payload: { + property: 'artifacts', + value: artifacts, + }, + }); + }; + + const getDashboardItem = (dashboard: DashboardItem) => ({ + value: dashboard.id, + label: dashboard.attributes.title, + }); + + const loadDashboards = useCallback(async () => { + if (contentManagement) { + const dashboards = await dashboardServiceProvider(contentManagement) + .fetchDashboards() + .catch(() => {}); + const dashboardOptions = (dashboards ?? []).map((dashboard: DashboardItem) => + getDashboardItem(dashboard) + ); + setDashboardList(dashboardOptions); + } + }, [contentManagement]); + + useMemo(() => { + loadDashboards(); + }, [loadDashboards]); + + return ( + <> + + + + + + + + + + ); +}; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.test.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.test.tsx index 78d35d066059c..0348b89262147 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.test.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.test.tsx @@ -8,8 +8,9 @@ */ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import { RuleDetails } from './rule_details'; const mockOnChange = jest.fn(); @@ -24,6 +25,9 @@ const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); describe('RuleDetails', () => { beforeEach(() => { useRuleFormState.mockReturnValue({ + plugins: { + contentManagement: {} as ContentManagementPublicStart, + }, formData: { name: 'test', tags: [], @@ -55,7 +59,10 @@ describe('RuleDetails', () => { test('Should allow tags to be changed', async () => { render(); - await userEvent.type(screen.getByTestId('comboBoxInput'), 'tag{enter}'); + await userEvent.type( + within(screen.getByTestId('ruleDetailsTagsInput')).getByTestId('comboBoxInput'), + 'tag{enter}' + ); expect(mockOnChange).toHaveBeenCalledWith({ type: 'setTags', payload: ['tag'], @@ -64,6 +71,9 @@ describe('RuleDetails', () => { test('Should display error', () => { useRuleFormState.mockReturnValue({ + plugins: { + contentManagement: {} as ContentManagementPublicStart, + }, formData: { name: 'test', tags: [], diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.tsx index dcfa1c75a331f..4c0be541f9f9e 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_details/rule_details.tsx @@ -19,9 +19,11 @@ import { import { RULE_NAME_INPUT_TITLE, RULE_TAG_INPUT_TITLE, RULE_TAG_PLACEHOLDER } from '../translations'; import { useRuleFormState, useRuleFormDispatch } from '../hooks'; import { OptionalFieldLabel } from '../optional_field_label'; +import { RuleDashboards } from './rule_dashboards'; export const RuleDetails = () => { - const { formData, baseErrors } = useRuleFormState(); + const { formData, baseErrors, plugins } = useRuleFormState(); + const { contentManagement } = plugins; const dispatch = useRuleFormDispatch(); @@ -71,44 +73,47 @@ export const RuleDetails = () => { }, [dispatch, tags]); return ( - - - - + + + - - - - - + + + + + - - - + label={RULE_TAG_INPUT_TITLE} + labelAppend={OptionalFieldLabel} + isInvalid={!!baseErrors?.tags?.length} + error={baseErrors?.tags} + > + + + + + {contentManagement && } + ); }; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx index efccb07d805e8..9595e85f872c6 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx @@ -85,6 +85,7 @@ export const RuleForm = ( ruleTypeRegistry, actionTypeRegistry, fieldsMetadata, + contentManagement, } = _plugins; const ruleFormComponent = useMemo(() => { @@ -104,6 +105,7 @@ export const RuleForm = ( ruleTypeRegistry, actionTypeRegistry, fieldsMetadata, + contentManagement, }; // Passing the MetaData type all the way down the component hierarchy is unnecessary, this type is @@ -176,6 +178,7 @@ export const RuleForm = ( docLinks, ruleTypeRegistry, actionTypeRegistry, + contentManagement, isServerless, id, ruleTypeId, diff --git a/src/platform/packages/shared/response-ops/rule_form/src/translations.ts b/src/platform/packages/shared/response-ops/rule_form/src/translations.ts index a204ff175d808..df3e1d8e8c5f3 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/translations.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/translations.ts @@ -103,6 +103,20 @@ export const ALERT_FLAPPING_DETECTION_DESCRIPTION = i18n.translate( } ); +export const ALERT_LINK_DASHBOARDS_TITLE = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleDefinition.alertLinkDashboardsTitle', + { + defaultMessage: 'Related dashboards', + } +); + +export const ALERT_LINK_DASHBOARDS_PLACEHOLDER = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleDefinition.alertLinkDashboardsTitle', + { + defaultMessage: 'Link related dashboards for investigation', + } +); + export const SCHEDULE_TITLE_PREFIX = i18n.translate( 'responseOpsRuleForm.ruleForm.ruleSchedule.scheduleTitlePrefix', { diff --git a/src/platform/packages/shared/response-ops/rule_form/src/types.ts b/src/platform/packages/shared/response-ops/rule_form/src/types.ts index bfb00a7982603..865b586d2b7ee 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/types.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/types.ts @@ -24,6 +24,7 @@ import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { ActionConnector, ActionTypeRegistryContract } from '@kbn/alerts-ui-shared'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import type { MinimumScheduleInterval, Rule, @@ -52,6 +53,7 @@ export interface RuleFormData { throttle?: Rule['throttle']; ruleTypeId?: Rule['ruleTypeId']; flapping?: Rule['flapping']; + artifacts?: Rule['artifacts']; } export interface RuleFormPlugins { @@ -70,6 +72,7 @@ export interface RuleFormPlugins { ruleTypeRegistry: RuleTypeRegistryContract; actionTypeRegistry: ActionTypeRegistryContract; fieldsMetadata: FieldsMetadataPublicStart; + contentManagement?: ContentManagementPublicStart; } export interface RuleFormState< @@ -107,3 +110,5 @@ export type { SanitizedRuleAction as RuleAction } from '@kbn/alerting-types'; export interface ValidationResult { errors: Record; } + +export type RuleDashboardsPlugins = Pick; diff --git a/src/platform/packages/shared/response-ops/rule_form/tsconfig.json b/src/platform/packages/shared/response-ops/rule_form/tsconfig.json index 40ef0d811c333..692d1fe480c07 100644 --- a/src/platform/packages/shared/response-ops/rule_form/tsconfig.json +++ b/src/platform/packages/shared/response-ops/rule_form/tsconfig.json @@ -31,6 +31,8 @@ "@kbn/core-i18n-browser", "@kbn/core-theme-browser", "@kbn/core-user-profile-browser", - "@kbn/fields-metadata-plugin" + "@kbn/fields-metadata-plugin", + "@kbn/content-management-plugin", + "@kbn/content-management-utils", ] } diff --git a/src/platform/plugins/shared/dashboard/public/index.ts b/src/platform/plugins/shared/dashboard/public/index.ts index bdbb27fe59460..731da36229f5b 100644 --- a/src/platform/plugins/shared/dashboard/public/index.ts +++ b/src/platform/plugins/shared/dashboard/public/index.ts @@ -22,7 +22,10 @@ export { DashboardListingTable } from './dashboard_listing'; export { DashboardTopNav } from './dashboard_top_nav'; export type { RedirectToProps } from './dashboard_app/types'; -export { type SearchDashboardsResponse } from './services/dashboard_content_management_service/lib/find_dashboards'; +export { + type FindDashboardsByIdResponse, + type SearchDashboardsResponse, +} from './services/dashboard_content_management_service/lib/find_dashboards'; export { DASHBOARD_APP_ID } from '../common/constants'; export { cleanEmptyKeys, DashboardAppLocatorDefinition } from '../common/locator/locator'; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc b/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc index 60618e3978783..040ff249ffcd5 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc @@ -33,7 +33,8 @@ "controls", "embeddable", "fieldsMetadata", - "uiActions" + "uiActions", + "contentManagement", ], "optionalPlugins": [ "cloud", diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx index 9fa6ca3d187b2..1680711739e01 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx @@ -38,6 +38,7 @@ import { ExpressionsStart } from '@kbn/expressions-plugin/public'; import { CloudSetup } from '@kbn/cloud-plugin/public'; import { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import { SharePluginStart } from '@kbn/share-plugin/public'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { ActionTypeRegistryContract, RuleTypeRegistryContract } from '../types'; import { @@ -87,6 +88,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { lens: LensPublicStart; fieldsMetadata: FieldsMetadataPublicStart; share?: SharePluginStart; + contentManagement?: ContentManagementPublicStart; } export const renderApp = (deps: TriggersAndActionsUiServices) => { diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx index 9be2cde33d058..3adca7c95e73a 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx @@ -27,6 +27,7 @@ export const RuleFormRoute = () => { docLinks, ruleTypeRegistry, actionTypeRegistry, + contentManagement, chrome, isServerless, setBreadcrumbs, @@ -74,6 +75,7 @@ export const RuleFormRoute = () => { docLinks, ruleTypeRegistry, actionTypeRegistry, + contentManagement, ...startServices, }} isServerless={isServerless} diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts index 04a3db90f3b9e..a97e2521fb1a5 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts @@ -16,6 +16,7 @@ import type { ManagementAppMountParams, ManagementSetup } from '@kbn/management- import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { PluginStartContract as AlertingStart } from '@kbn/alerting-plugin/public'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -172,6 +173,7 @@ interface PluginsStart { lens: LensPublicStart; fieldsMetadata: FieldsMetadataPublicStart; uiActions: UiActionsStart; + contentManagement?: ContentManagementPublicStart; share: SharePluginStart; } @@ -312,6 +314,7 @@ export class Plugin fieldFormats: pluginsStart.fieldFormats, lens: pluginsStart.lens, fieldsMetadata: pluginsStart.fieldsMetadata, + contentManagement: pluginsStart.contentManagement, share: pluginsStart.share, }); }, diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json index e2f1c64f3d571..4488bcf51593e 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json @@ -84,7 +84,8 @@ "@kbn/embeddable-plugin", "@kbn/presentation-publishing", "@kbn/esql-utils", - "@kbn/esql-ast" + "@kbn/esql-ast", + "@kbn/content-management-plugin" ], "exclude": ["target/**/*"] } diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx index 6ddf54555fd55..5d1ce1647f8d9 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx @@ -13,6 +13,7 @@ import { observabilityAIAssistantPluginMock } from '@kbn/observability-ai-assist import { useBreadcrumbs, TagsList } from '@kbn/observability-shared-plugin/public'; import { RuleTypeModel, ValidationResult } from '@kbn/triggers-actions-ui-plugin/public'; import { ruleTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/rule_type_registry.mock'; +import { dashboardServiceProvider } from '@kbn/response-ops-rule-form/src/common'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Chance } from 'chance'; @@ -36,6 +37,7 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('../../utils/kibana_react'); +jest.mock('@kbn/response-ops-rule-form/src/common'); const validationMethod = (): ValidationResult => ({ errors: {} }); const ruleType: RuleTypeModel = { id: 'logs.alert.document.count', @@ -51,6 +53,8 @@ const ruleTypeRegistry = ruleTypeRegistryMock.create(); const useKibanaMock = useKibana as jest.Mock; +const dashboardServiceProviderMock = dashboardServiceProvider as jest.Mock; + const mockObservabilityAIAssistant = observabilityAIAssistantPluginMock.createStartContract(); const mockKibana = () => { @@ -67,6 +71,7 @@ const mockKibana = () => { }, observabilityAIAssistant: mockObservabilityAIAssistant, theme: {}, + dashboard: {}, }, }); }; @@ -80,6 +85,16 @@ jest.mock('../../hooks/use_fetch_rule', () => { id: 'ruleId', name: 'ruleName', consumer: 'logs', + artifacts: { + dashboards: [ + { + id: 'dashboard-1', + }, + { + id: 'dashboard-2', + }, + ], + }, }, }), }; @@ -97,6 +112,14 @@ const TagsListMock = TagsList as jest.Mock; usePerformanceContextMock.mockReturnValue({ onPageReady: jest.fn() }); +dashboardServiceProviderMock.mockReturnValue({ + fetchValidDashboards: jest.fn().mockResolvedValue([ + { + id: 'dashboard-1', + }, + ]), +}); + const chance = new Chance(); const params = { alertId: chance.guid(), diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx index 68546f8e1454f..fa37900b91e82 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx @@ -10,6 +10,7 @@ import { useHistory, useLocation, useParams } from 'react-router-dom'; import { usePerformanceContext } from '@kbn/ebt-tools'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { FindDashboardsByIdResponse } from '@kbn/dashboard-plugin/public'; import { EuiEmptyPrompt, EuiPanel, @@ -19,6 +20,7 @@ import { EuiTabbedContentTab, useEuiTheme, EuiFlexGroup, + EuiNotificationBadge, } from '@elastic/eui'; import { AlertStatus, @@ -32,6 +34,7 @@ import { RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public'; import dedent from 'dedent'; import { AlertFieldsTable } from '@kbn/alerts-ui-shared/src/alert_fields_table'; +import { dashboardServiceProvider } from '@kbn/response-ops-rule-form/src/common'; import { css } from '@emotion/react'; import { omit } from 'lodash'; import { BetaBadge } from '../../components/experimental_badge'; @@ -55,6 +58,7 @@ import { CustomThresholdRule } from '../../components/custom_threshold/component import { AlertDetailContextualInsights } from './alert_details_contextual_insights'; import { AlertHistoryChart } from './components/alert_history'; import StaleAlert from './components/stale_alert'; +import { RelatedDashboards } from './components/related_dashboards'; interface AlertDetailsPathParams { alertId: string; @@ -73,6 +77,7 @@ const OVERVIEW_TAB_ID = 'overview'; const METADATA_TAB_ID = 'metadata'; const RELATED_ALERTS_TAB_ID = 'related_alerts'; const ALERT_DETAILS_TAB_URL_STORAGE_KEY = 'tabId'; +const RELATED_DASHBOARDS_TAB_ID = 'related_dashboards'; type TabId = typeof OVERVIEW_TAB_ID | typeof METADATA_TAB_ID | typeof RELATED_ALERTS_TAB_ID; export const getPageTitle = (ruleCategory: string) => { @@ -96,6 +101,7 @@ export function AlertDetails() { observabilityAIAssistant, uiSettings, serverless, + contentManagement, } = useKibana().services; const { onPageReady } = usePerformanceContext(); @@ -122,6 +128,8 @@ export function AlertDetails() { ? (urlTabId as TabId) : OVERVIEW_TAB_ID; }); + const [validDashboards, setValidDashboards] = useState([]); + const linkedDashboards = React.useMemo(() => rule?.artifacts?.dashboards ?? [], [rule]); const handleSetTabId = async (tabId: TabId) => { setActiveTabId(tabId); @@ -189,6 +197,18 @@ export function AlertDetails() { } }, [onPageReady, alertDetail, isLoading, activeTabId]); + useEffect(() => { + const fetchValidDashboards = async () => { + const dashboardIds = linkedDashboards.map((dashboard: { id: string }) => dashboard.id); + const findDashboardsService = dashboardServiceProvider(contentManagement); + const existingDashboards = await findDashboardsService.fetchValidDashboards(dashboardIds); + + setValidDashboards(existingDashboards.length ? existingDashboards : []); + }; + + fetchValidDashboards(); + }, [rule, contentManagement, linkedDashboards]); + if (isLoading) { return ; } @@ -275,6 +295,12 @@ export function AlertDetails() { ); + const relatedDashboardsTab = alertDetail ? ( + + ) : ( + + ); + const tabs: EuiTabbedContentTab[] = [ { id: OVERVIEW_TAB_ID, @@ -307,6 +333,26 @@ export function AlertDetails() { 'data-test-subj': 'relatedAlertsTab', content: , }, + ...(validDashboards?.length + ? [ + { + id: RELATED_DASHBOARDS_TAB_ID, + name: ( + <> + {' '} + + {validDashboards?.length} + + + ), + 'data-test-subj': 'relatedDashboardsTab', + content: relatedDashboardsTab, + }, + ] + : []), ]; return ( diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_dashboards.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_dashboards.tsx new file mode 100644 index 0000000000000..19f2223803665 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_dashboards.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; +import { DashboardLocatorParams } from '@kbn/dashboard-plugin/common'; +import { + EuiTitle, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, +} from '@elastic/eui'; +import { useKibana } from '../../../utils/kibana_react'; +import { TopAlert } from '../../..'; + +interface RelatedDashboardsProps { + alert: TopAlert; + relatedDashboards: Array<{ id: string }>; +} + +export function RelatedDashboards({ alert, relatedDashboards }: RelatedDashboardsProps) { + const [dashboardsMeta, setDashboardsMeta] = useState< + Array<{ id: string; title: string; description: string }> + >([]); + + const { + services: { + share: { url: urlService }, + dashboard: dashboardService, + }, + } = useKibana(); + + const dashboardLocator = urlService.locators.get(DASHBOARD_APP_LOCATOR); + + useEffect(() => { + if (!relatedDashboards?.length || !dashboardService) { + return; + } + + const fetchDashboards = async () => { + const dashboardPromises = relatedDashboards.map(async (dashboard) => { + try { + const findDashboardsService = await dashboardService.findDashboardsService(); + const response = await findDashboardsService.findById(dashboard.id); + + if (response.status === 'error') { + return null; + } + + return { + id: dashboard.id, + title: response.attributes.title, + description: response.attributes.description, + }; + } catch (dashboardError) { + return null; + } + }); + + const results = await Promise.all(dashboardPromises); + + // Filter out null results (failed dashboard fetches) + const validDashboards = results.filter(Boolean) as Array<{ + id: string; + title: string; + description: string; + }>; + + setDashboardsMeta(validDashboards); + }; + + fetchDashboards(); + }, [relatedDashboards, dashboardService, setDashboardsMeta]); + + return ( +
+ + + + +

+ {i18n.translate('xpack.observability.alertDetails.relatedDashboards', { + defaultMessage: 'Linked dashboards', + })} +

+
+ +
+
+ + + {dashboardsMeta.map((dashboard) => ( + <> + + + + { + e.preventDefault(); + if (dashboardLocator) { + const url = await dashboardLocator.getUrl({ + dashboardId: dashboard.id, + }); + window.open(url, '_blank'); + } else { + console.error('Dashboard locator is not available'); + } + }} + > + {dashboard.title} + + + + {dashboard.description} + + + + + + ))} +
+ ); +} diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/rules/rule.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/rules/rule.tsx index 401e2e04ebbac..daa9217350e84 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/rules/rule.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/rules/rule.tsx @@ -33,6 +33,7 @@ export function RulePage() { actionTypeRegistry, ruleTypeRegistry, chrome, + contentManagement, ...startServices } = useKibana().services; const { ObservabilityPageTemplate } = usePluginContext(); @@ -84,6 +85,7 @@ export function RulePage() { docLinks, ruleTypeRegistry, actionTypeRegistry, + contentManagement, ...startServices, }} id={id} diff --git a/x-pack/solutions/observability/plugins/observability/public/plugin.ts b/x-pack/solutions/observability/plugins/observability/public/plugin.ts index f5ced4272aa04..8696af87a6315 100644 --- a/x-pack/solutions/observability/plugins/observability/public/plugin.ts +++ b/x-pack/solutions/observability/plugins/observability/public/plugin.ts @@ -6,6 +6,7 @@ */ import { CasesDeepLinkId, CasesPublicStart, getCasesDeepLinks } from '@kbn/cases-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; @@ -133,6 +134,7 @@ export interface ObservabilityPublicPluginsStart { cases: CasesPublicStart; charts: ChartsPluginStart; contentManagement: ContentManagementPublicStart; + dashboard: DashboardStart; data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; dataViewEditor: DataViewEditorStart; diff --git a/x-pack/solutions/observability/plugins/observability/tsconfig.json b/x-pack/solutions/observability/plugins/observability/tsconfig.json index 05016b4b93c7d..d6b65767f1e11 100644 --- a/x-pack/solutions/observability/plugins/observability/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability/tsconfig.json @@ -114,10 +114,11 @@ "@kbn/data-service", "@kbn/ebt-tools", "@kbn/response-ops-rule-params", - "@kbn/dashboard-plugin", "@kbn/fields-metadata-plugin", "@kbn/controls-plugin", "@kbn/core-http-browser", + "@kbn/dashboard-plugin", + "@kbn/deeplinks-analytics", "@kbn/object-utils", "@kbn/task-manager-plugin", "@kbn/core-saved-objects-server" diff --git a/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/alert_rules/custom_tls_alert.journey.ts b/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/alert_rules/custom_tls_alert.journey.ts index f8870c75c01ee..4db932cdd2661 100644 --- a/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/alert_rules/custom_tls_alert.journey.ts +++ b/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/alert_rules/custom_tls_alert.journey.ts @@ -78,7 +78,7 @@ journey(`CustomTLSAlert`, async ({ page, params }) => { step('Should filter monitors by type', async () => { await page.getByRole('button', { name: 'Type All' }).click(); - await page.getByTestId('comboBoxInput').click(); + await page.getByTestId('monitorTypeField').click(); await page.getByRole('option', { name: 'http' }).click(); await page.getByTestId('ruleDefinition').getByRole('button', { name: 'Type http' }).click(); await expect(page.getByTestId('syntheticsStatusRuleVizMonitorQueryIDsButton')).toHaveText(