diff --git a/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/latest.ts b/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/latest.ts index 863a4bc5a7015..d80af6ebceab1 100644 --- a/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/latest.ts +++ b/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/latest.ts @@ -6,7 +6,12 @@ */ export { type RelevantPanel, relevantPanelSchema } from './schema/relevant_panel/v1'; -export { type RelatedDashboard, relatedDashboardSchema } from './schema/related_dashboard/v1'; +export { + type RelatedDashboard, + type SuggestedDashboard, + relatedDashboardSchema, + suggestedDashboardSchema, +} from './schema/related_dashboard/v1'; export { type GetRelatedDashboardsResponse, getRelatedDashboardsResponseSchema, diff --git a/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/schema/related_dashboard/v1.ts b/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/schema/related_dashboard/v1.ts index 159aec1b7cd91..e26f7ebf65ba6 100644 --- a/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/schema/related_dashboard/v1.ts +++ b/x-pack/solutions/observability/packages/kbn-observability-schema/related_dashboards/schema/related_dashboard/v1.ts @@ -9,6 +9,18 @@ import { z } from '@kbn/zod'; import { relevantPanelSchema } from '../relevant_panel/latest'; export const relatedDashboardSchema = z.object({ + id: z.string(), + title: z.string(), + matchedBy: z.object({ + fields: z.array(z.string()).optional(), + index: z.array(z.string()).optional(), + linked: z.boolean().optional(), + }), + relevantPanelCount: z.number().optional(), + relevantPanels: z.array(relevantPanelSchema).optional(), +}); + +export const suggestedDashboardSchema = z.object({ id: z.string(), title: z.string(), matchedBy: z.object({ @@ -21,3 +33,4 @@ export const relatedDashboardSchema = z.object({ }); export type RelatedDashboard = z.output; +export type SuggestedDashboard = z.output; diff --git a/x-pack/solutions/observability/plugins/observability/server/routes/alerts/route.ts b/x-pack/solutions/observability/plugins/observability/server/routes/alerts/route.ts index 3f157376718a1..bbc2238c09083 100644 --- a/x-pack/solutions/observability/plugins/observability/server/routes/alerts/route.ts +++ b/x-pack/solutions/observability/plugins/observability/server/routes/alerts/route.ts @@ -42,7 +42,8 @@ const alertsDynamicDashboardSuggestions = createObservabilityServerRoute({ }); const alertsClient = await ruleRegistry.getRacClientWithRequest(request); - const investigateAlertsClient = new InvestigateAlertsClient(alertsClient); + const rulesClient = await ruleRegistry.alerting.getRulesClientWithRequest(request); + const investigateAlertsClient = new InvestigateAlertsClient(alertsClient, rulesClient); const dashboardParser = new RelatedDashboardsClient( logger, @@ -51,10 +52,11 @@ const alertsDynamicDashboardSuggestions = createObservabilityServerRoute({ alertId ); try { - const { suggestedDashboards } = await dashboardParser.fetchSuggestedDashboards(); + const { suggestedDashboards, linkedDashboards } = + await dashboardParser.fetchRelatedDashboards(); return { suggestedDashboards, - linkedDashboards: [], + linkedDashboards, }; } catch (e) { if (e instanceof AlertNotFoundError) { diff --git a/x-pack/solutions/observability/plugins/observability/server/services/alert_data.ts b/x-pack/solutions/observability/plugins/observability/server/services/alert_data.ts index 27563c6fd4c2b..8eba20f25705b 100644 --- a/x-pack/solutions/observability/plugins/observability/server/services/alert_data.ts +++ b/x-pack/solutions/observability/plugins/observability/server/services/alert_data.ts @@ -7,21 +7,27 @@ import { omit } from 'lodash'; import { CustomThresholdParams } from '@kbn/response-ops-rule-params/custom_threshold'; +import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; import { DataViewSpec } from '@kbn/response-ops-rule-params/common'; import { ALERT_RULE_PARAMETERS, ALERT_RULE_TYPE_ID, + ALERT_RULE_UUID, OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, fields as TECHNICAL_ALERT_FIELDS, } from '@kbn/rule-data-utils'; export class AlertData { - constructor(private alert: any) {} + constructor(private alert: Awaited>) {} getRuleParameters() { return this.alert[ALERT_RULE_PARAMETERS]; } + getRuleId() { + return this.alert[ALERT_RULE_UUID]; + } + getRelevantRuleFields(): Set { const ruleParameters = this.getRuleParameters(); const relevantFields = new Set(); @@ -81,7 +87,7 @@ export class AlertData { } } - getRuleTypeId() { + getRuleTypeId(): string | undefined { return this.alert[ALERT_RULE_TYPE_ID]; } } diff --git a/x-pack/solutions/observability/plugins/observability/server/services/investigate_alerts_client.ts b/x-pack/solutions/observability/plugins/observability/server/services/investigate_alerts_client.ts index a83b96775f737..a5ec5ebb267c4 100644 --- a/x-pack/solutions/observability/plugins/observability/server/services/investigate_alerts_client.ts +++ b/x-pack/solutions/observability/plugins/observability/server/services/investigate_alerts_client.ts @@ -5,12 +5,13 @@ * 2.0. */ import { OBSERVABILITY_RULE_TYPE_IDS } from '@kbn/rule-data-utils'; -import { AlertsClient } from '@kbn/rule-registry-plugin/server'; +import type { RulesClientApi } from '@kbn/alerting-plugin/server/types'; +import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; import { AlertNotFoundError } from '../common/errors/alert_not_found_error'; import { AlertData } from './alert_data'; export class InvestigateAlertsClient { - constructor(private alertsClient: AlertsClient) {} + constructor(private alertsClient: AlertsClient, private rulesClient: RulesClientApi) {} async getAlertById(alertId: string): Promise { const indices = (await this.getAlertsIndices()) || []; @@ -31,6 +32,10 @@ export class InvestigateAlertsClient { } } + async getRuleById(ruleId: string) { + return await this.rulesClient.get({ id: ruleId }); + } + async getAlertsIndices() { return await this.alertsClient.getAuthorizedAlertsIndices(OBSERVABILITY_RULE_TYPE_IDS); } diff --git a/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.test.ts b/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.test.ts index 14b8036d76b91..91c3a18775a71 100644 --- a/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.test.ts +++ b/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.test.ts @@ -17,6 +17,11 @@ describe('RelatedDashboardsClient', () => { let alertsClient: jest.Mocked; let alertId: string; let client: RelatedDashboardsClient; + const baseMockAlert = { + getAllRelevantFields: jest.fn().mockReturnValue(['field1', 'field2']), + getRuleQueryIndex: jest.fn().mockReturnValue('index1'), + getRuleId: jest.fn().mockReturnValue('rule-id'), + } as unknown as AlertData; beforeEach(() => { logger = { @@ -39,10 +44,16 @@ describe('RelatedDashboardsClient', () => { pagination: { total: 2 }, }, }), + get: jest.fn(), } as unknown as jest.Mocked>; alertsClient = { getAlertById: jest.fn(), + getRuleById: jest.fn().mockResolvedValue({ + artifacts: { + dashboards: [], + }, + }), } as unknown as jest.Mocked; alertId = 'test-alert-id'; @@ -55,18 +66,13 @@ describe('RelatedDashboardsClient', () => { // @ts-ignore next-line alertsClient.getAlertById.mockResolvedValue(null); - await expect(client.fetchSuggestedDashboards()).rejects.toThrow( + await expect(client.fetchRelatedDashboards()).rejects.toThrow( `Alert with id ${alertId} not found. Could not fetch related dashboards.` ); }); it('should fetch dashboards and return suggested dashboards', async () => { - const mockAlert = { - getAllRelevantFields: jest.fn().mockReturnValue(['field1', 'field2']), - getRuleQueryIndex: jest.fn().mockReturnValue('index1'), - } as unknown as AlertData; - - alertsClient.getAlertById.mockResolvedValue(mockAlert); + alertsClient.getAlertById.mockResolvedValue(baseMockAlert); dashboardClient.search.mockResolvedValue({ contentTypeId: 'dashboard', result: { @@ -75,7 +81,7 @@ describe('RelatedDashboardsClient', () => { }, }); - const result = await client.fetchSuggestedDashboards(); + const result = await client.fetchRelatedDashboards(); expect(result.suggestedDashboards).toEqual([]); expect(alertsClient.getAlertById).toHaveBeenCalledWith(alertId); @@ -83,12 +89,16 @@ describe('RelatedDashboardsClient', () => { it('should sort dashboards by score', async () => { const mockAlert = { + ...baseMockAlert, getAllRelevantFields: jest.fn().mockReturnValue(['field1']), getRuleQueryIndex: jest.fn().mockReturnValue('index1'), } as unknown as AlertData; alertsClient.getAlertById.mockResolvedValue(mockAlert); + // @ts-ignore next-line + client.setAlert(mockAlert); + dashboardClient.search.mockResolvedValue({ contentTypeId: 'dashboard', result: { @@ -150,7 +160,7 @@ describe('RelatedDashboardsClient', () => { }, }); - const result = await client.fetchSuggestedDashboards(); + const result = await client.fetchRelatedDashboards(); expect(result.suggestedDashboards).toEqual([ { @@ -239,6 +249,7 @@ describe('RelatedDashboardsClient', () => { it('should return only the top 10 results', async () => { const mockAlert = { + ...baseMockAlert, getAllRelevantFields: jest.fn().mockReturnValue(['field1']), getRuleQueryIndex: jest.fn().mockReturnValue('index1'), } as unknown as AlertData; @@ -276,19 +287,23 @@ describe('RelatedDashboardsClient', () => { }, }); - const result = await client.fetchSuggestedDashboards(); + const { suggestedDashboards } = await client.fetchRelatedDashboards(); - expect(result.suggestedDashboards).toHaveLength(10); + expect(suggestedDashboards).toHaveLength(10); }); it('should deduplicate dashboards found by field and index', async () => { const mockAlert = { + ...baseMockAlert, getAllRelevantFields: jest.fn().mockReturnValue(['field1']), getRuleQueryIndex: jest.fn().mockReturnValue('index1'), } as unknown as AlertData; alertsClient.getAlertById.mockResolvedValue(mockAlert); + // @ts-ignore next-line + client.setAlert(mockAlert); + dashboardClient.search.mockResolvedValue({ contentTypeId: 'dashboard', result: { @@ -320,12 +335,12 @@ describe('RelatedDashboardsClient', () => { }, }); - const result = await client.fetchSuggestedDashboards(); + const { suggestedDashboards } = await client.fetchRelatedDashboards(); - expect(result.suggestedDashboards).toHaveLength(1); + expect(suggestedDashboards).toHaveLength(1); // should return only one dashboard even though it was found by both internal methods // should mark the relevant panel as matching by index and field - expect(result.suggestedDashboards).toEqual([ + expect(suggestedDashboards).toEqual([ { id: 'dashboard1', matchedBy: { fields: ['field1'], index: ['index1'] }, @@ -370,6 +385,7 @@ describe('RelatedDashboardsClient', () => { }, }); + // @ts-ignore next-line await client.fetchDashboards({ page: 1, perPage: 2 }); expect(dashboardClient.search).toHaveBeenCalledWith( @@ -402,9 +418,11 @@ describe('RelatedDashboardsClient', () => { }, } as any); + // @ts-ignore next-line const resultWithoutMatch = client.getDashboardsByIndex('index1'); expect(resultWithoutMatch.dashboards).toEqual([]); + // @ts-ignore next-line const resultWithMatch = client.getDashboardsByIndex('index2'); expect(resultWithMatch.dashboards).toHaveLength(1); expect(resultWithMatch.dashboards[0].id).toBe('dashboard1'); @@ -421,6 +439,7 @@ describe('RelatedDashboardsClient', () => { { panel: { panelIndex: '1' }, matchedBy: { fields: ['field1'] } }, ]; + // @ts-ignore next-line const result = client.dedupePanels(panels as any); expect(result).toHaveLength(1); @@ -431,16 +450,19 @@ describe('RelatedDashboardsClient', () => { describe('getScore', () => { it('should calculate the relevance score for a dashboard', () => { const mockAlert = { + ...baseMockAlert, getAllRelevantFields: jest.fn().mockReturnValue(['field1', 'field2']), getRuleQueryIndex: jest.fn().mockReturnValue('index1'), } as unknown as AlertData; + // @ts-ignore next-line client.setAlert(mockAlert); const dashboard = { matchedBy: { fields: ['field1'], index: ['index1'] }, } as any; + // @ts-ignore next-line const score = client.getScore(dashboard); expect(score).toBeCloseTo(2 / 3); @@ -449,9 +471,245 @@ describe('RelatedDashboardsClient', () => { matchedBy: { fields: ['field1', 'field2'], index: ['index1'] }, } as any; + // @ts-ignore next-line const score2 = client.getScore(dashboard2); expect(score2).toBe(1); }); }); + + describe('Linked Dashboards', () => { + describe('getLinkedDashboards', () => { + it('should throw an error if no alert is set', async () => { + // @ts-ignore next-line + client.setAlert(null); + + // @ts-ignore next-line + await expect(client.getLinkedDashboards()).rejects.toThrow( + `Alert with id ${alertId} not found. Could not fetch related dashboards.` + ); + }); + + it('should return an empty array if no rule ID is found', async () => { + const mockAlert = { + ...baseMockAlert, + getRuleId: jest.fn().mockReturnValue(null), + } as unknown as AlertData; + + // @ts-ignore next-line + client.setAlert(mockAlert); + + // @ts-ignore next-line + await expect(client.getLinkedDashboards()).rejects.toThrow( + `Alert with id ${alertId} does not have a rule ID. Could not fetch linked dashboards.` + ); + }); + + it('should return an empty array if no rule is found', async () => { + const mockAlert = { + getRuleId: jest.fn().mockReturnValue('rule-id'), + } as unknown as AlertData; + + // @ts-ignore next-line + client.setAlert(mockAlert); + alertsClient.getRuleById = jest.fn().mockResolvedValue(null); + + // @ts-ignore next-line + await expect(client.getLinkedDashboards()).rejects.toThrow( + `Rule with id rule-id not found. Could not fetch linked dashboards for alert with id ${alertId}.` + ); + }); + + it('should return linked dashboards based on rule artifacts', async () => { + const mockAlert = { + getRuleId: jest.fn().mockReturnValue('rule-id'), + } as unknown as AlertData; + + // @ts-ignore next-line + client.setAlert(mockAlert); + + alertsClient.getRuleById = jest.fn().mockResolvedValue({ + artifacts: { + dashboards: [{ id: 'dashboard1' }, { id: 'dashboard2' }], + }, + }); + + dashboardClient.get = jest + .fn() + .mockResolvedValueOnce({ + result: { item: { id: 'dashboard1', attributes: { title: 'Dashboard 1' } } }, + }) + .mockResolvedValueOnce({ + result: { item: { id: 'dashboard2', attributes: { title: 'Dashboard 2' } } }, + }); + + // @ts-ignore next-line + const result = await client.getLinkedDashboards(); + + expect(result).toEqual([ + { id: 'dashboard1', title: 'Dashboard 1', matchedBy: { linked: true } }, + { id: 'dashboard2', title: 'Dashboard 2', matchedBy: { linked: true } }, + ]); + }); + }); + + describe('getLinkedDashboardsByIds', () => { + it('should return linked dashboards by IDs', async () => { + dashboardClient.get = jest + .fn() + .mockResolvedValueOnce({ + result: { item: { id: 'dashboard1', attributes: { title: 'Dashboard 1' } } }, + }) + .mockResolvedValueOnce({ + result: { item: { id: 'dashboard2', attributes: { title: 'Dashboard 2' } } }, + }); + // @ts-ignore next-line + const result = await client.getLinkedDashboardsByIds(['dashboard1', 'dashboard2']); + + expect(result).toEqual([ + { id: 'dashboard1', title: 'Dashboard 1', matchedBy: { linked: true } }, + { id: 'dashboard2', title: 'Dashboard 2', matchedBy: { linked: true } }, + ]); + expect(dashboardClient.get).toHaveBeenCalledTimes(2); + expect(dashboardClient.get).toHaveBeenCalledWith('dashboard1'); + expect(dashboardClient.get).toHaveBeenCalledWith('dashboard2'); + }); + + it('should handle empty IDs array gracefully', async () => { + dashboardClient.get = jest.fn(); + // @ts-ignore next-line + const result = await client.getLinkedDashboardsByIds([]); + + expect(result).toEqual([]); + expect(dashboardClient.get).not.toHaveBeenCalled(); + }); + + it('should handle errors when fetching dashboards', async () => { + dashboardClient.get = jest.fn().mockRejectedValue(new Error('Dashboard fetch failed')); + + // @ts-ignore next-line + await expect(client.getLinkedDashboardsByIds(['dashboard1'])).rejects.toThrow( + 'Dashboard fetch failed' + ); + }); + }); + }); + + describe('deduplicateDashboards', () => { + it('should deduplicate suggested and linked dashboards', async () => { + const mockAlert = { + ...baseMockAlert, + getAllRelevantFields: jest.fn().mockReturnValue(['field1']), + getRuleQueryIndex: jest.fn().mockReturnValue('index1'), + } as unknown as AlertData; + + alertsClient.getAlertById.mockResolvedValue(mockAlert); + // @ts-ignore next-line + alertsClient.getRuleById.mockResolvedValue({ + artifacts: { + dashboards: [{ id: 'dashboard2' }], + }, + }); + + dashboardClient.get = jest.fn().mockResolvedValueOnce({ + result: { item: { id: 'dashboard2', attributes: { title: 'Dashboard 2' } } }, + }); + + dashboardClient.search.mockResolvedValue({ + contentTypeId: 'dashboard', + result: { + hits: [ + { + id: 'dashboard1', + attributes: { + title: 'Dashboard 1', + panels: [ + { + type: 'lens', + panelIndex: '123', + panelConfig: { + attributes: { + references: [{ name: 'indexpattern', id: 'index1' }], // matches by index which is handled by getDashboardsByIndex + state: { + datasourceStates: { + formBased: { layers: [{ columns: [{ sourceField: 'field1' }] }] }, // matches by field which is handled by getDashboardsByField + }, + }, + }, + }, + }, + ], + }, + }, + { + id: 'dashboard2', + attributes: { + title: 'Dashboard 2', + panels: [ + { + type: 'lens', + panelIndex: '123', + panelConfig: { + attributes: { + references: [{ name: 'indexpattern', id: 'index1' }], + state: { + datasourceStates: { + formBased: { layers: [{ columns: [{ sourceField: 'field2' }] }] }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + pagination: { total: 1 }, + }, + }); + + const { suggestedDashboards, linkedDashboards } = await client.fetchRelatedDashboards(); + expect(linkedDashboards).toHaveLength(1); + expect(linkedDashboards).toEqual([ + { + id: 'dashboard2', + title: 'Dashboard 2', + matchedBy: { linked: true }, + }, + ]); + + expect(suggestedDashboards).toHaveLength(1); + // should return only one dashboard even though it was found by both internal methods + // should mark the relevant panel as matching by index and field + expect(suggestedDashboards).toEqual([ + { + id: 'dashboard1', + matchedBy: { fields: ['field1'], index: ['index1'] }, + relevantPanelCount: 1, + relevantPanels: [ + { + matchedBy: { index: ['index1'], fields: ['field1'] }, + panel: { + panelConfig: { + attributes: { + references: [{ id: 'index1', name: 'indexpattern' }], + state: { + datasourceStates: { + formBased: { layers: [{ columns: [{ sourceField: 'field1' }] }] }, + }, + }, + }, + }, + panelIndex: '123', + title: undefined, + type: 'lens', + }, + }, + ], + score: 0.5, + title: 'Dashboard 1', + }, + ]); + }); + }); }); diff --git a/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.ts b/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.ts index c7bf68c93af3f..997bffe39d38d 100644 --- a/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.ts +++ b/x-pack/solutions/observability/plugins/observability/server/services/related_dashboards_client.ts @@ -4,20 +4,24 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { v4 as uuidv4 } from 'uuid'; +import { omit } from 'lodash'; import { IContentClient } from '@kbn/content-management-plugin/server/types'; import type { Logger, SavedObjectsFindResult } from '@kbn/core/server'; import { isDashboardSection } from '@kbn/dashboard-plugin/common'; import type { DashboardAttributes, DashboardPanel } from '@kbn/dashboard-plugin/server'; -import type { LensAttributes } from '@kbn/lens-embeddable-utils'; import type { FieldBasedIndexPatternColumn, GenericIndexPatternColumn, } from '@kbn/lens-plugin/public'; -import type { RelatedDashboard, RelevantPanel } from '@kbn/observability-schema'; -import { v4 as uuidv4 } from 'uuid'; -import type { AlertData } from './alert_data'; +import type { LensAttributes } from '@kbn/lens-embeddable-utils'; +import type { + RelevantPanel, + RelatedDashboard, + SuggestedDashboard, +} from '@kbn/observability-schema'; import type { InvestigateAlertsClient } from './investigate_alerts_client'; +import type { AlertData } from './alert_data'; type Dashboard = SavedObjectsFindResult; export class RelatedDashboardsClient { @@ -31,35 +35,56 @@ export class RelatedDashboardsClient { private alertId: string ) {} - setAlert(alert: AlertData) { - this.alert = alert; - } - - async fetchSuggestedDashboards(): Promise<{ suggestedDashboards: RelatedDashboard[] }> { - const allRelatedDashboards = new Set(); - const relevantDashboardsById = new Map(); - const [alert] = await Promise.all([ + public async fetchRelatedDashboards(): Promise<{ + suggestedDashboards: RelatedDashboard[]; + linkedDashboards: RelatedDashboard[]; + }> { + const [alertDocument] = await Promise.all([ this.alertsClient.getAlertById(this.alertId), this.fetchFirst500Dashboards(), ]); - this.setAlert(alert); - if (!this.alert) { + this.setAlert(alertDocument); + const [suggestedDashboards, linkedDashboards] = await Promise.all([ + this.fetchSuggestedDashboards(), + this.getLinkedDashboards(), + ]); + const filteredSuggestedDashboards = suggestedDashboards.filter( + (suggested) => !linkedDashboards.some((linked) => linked.id === suggested.id) + ); + return { + suggestedDashboards: filteredSuggestedDashboards.slice(0, 10), // limit to 10 suggested dashboards + linkedDashboards, + }; + } + + private setAlert(alert: AlertData) { + this.alert = alert; + } + + private checkAlert(): AlertData { + if (!this.alert) throw new Error( `Alert with id ${this.alertId} not found. Could not fetch related dashboards.` ); - } + return this.alert; + } + + private async fetchSuggestedDashboards(): Promise { + const alert = this.checkAlert(); + const allSuggestedDashboards = new Set(); + const relevantDashboardsById = new Map(); const index = await this.getRuleQueryIndex(); - const allRelevantFields = this.alert.getAllRelevantFields(); + const allRelevantFields = alert.getAllRelevantFields(); if (index) { const { dashboards } = this.getDashboardsByIndex(index); - dashboards.forEach((dashboard) => allRelatedDashboards.add(dashboard)); + dashboards.forEach((dashboard) => allSuggestedDashboards.add(dashboard)); } if (allRelevantFields.length > 0) { const { dashboards } = this.getDashboardsByField(allRelevantFields); - dashboards.forEach((dashboard) => allRelatedDashboards.add(dashboard)); + dashboards.forEach((dashboard) => allSuggestedDashboards.add(dashboard)); } - allRelatedDashboards.forEach((dashboard) => { + allSuggestedDashboards.forEach((dashboard) => { const dedupedPanels = this.dedupePanels([ ...(relevantDashboardsById.get(dashboard.id)?.relevantPanels || []), ...dashboard.relevantPanels, @@ -79,10 +104,10 @@ export class RelatedDashboardsClient { const sortedDashboards = Array.from(relevantDashboardsById.values()).sort((a, b) => { return b.score - a.score; }); - return { suggestedDashboards: sortedDashboards.slice(0, 10) }; + return sortedDashboards; } - async fetchDashboards({ + private async fetchDashboards({ page, perPage = 20, limit, @@ -112,14 +137,14 @@ export class RelatedDashboardsClient { await this.fetchDashboards({ page: page + 1, perPage, limit }); } - async fetchFirst500Dashboards() { + private async fetchFirst500Dashboards() { await this.fetchDashboards({ page: 1, perPage: 500, limit: 500 }); } - getDashboardsByIndex(index: string): { - dashboards: RelatedDashboard[]; + private getDashboardsByIndex(index: string): { + dashboards: SuggestedDashboard[]; } { - const relevantDashboards: RelatedDashboard[] = []; + const relevantDashboards: SuggestedDashboard[] = []; this.dashboardsById.forEach((d) => { const panels = d.attributes.panels; const matchingPanels = this.getPanelsByIndex(index, panels); @@ -148,7 +173,7 @@ export class RelatedDashboardsClient { return { dashboards: relevantDashboards }; } - dedupePanels(panels: RelevantPanel[]): RelevantPanel[] { + private dedupePanels(panels: RelevantPanel[]): RelevantPanel[] { const uniquePanels = new Map(); panels.forEach((p) => { uniquePanels.set(p.panel.panelIndex, { @@ -159,10 +184,10 @@ export class RelatedDashboardsClient { return Array.from(uniquePanels.values()); } - getDashboardsByField(fields: string[]): { - dashboards: RelatedDashboard[]; + private getDashboardsByField(fields: string[]): { + dashboards: SuggestedDashboard[]; } { - const relevantDashboards: RelatedDashboard[] = []; + const relevantDashboards: SuggestedDashboard[] = []; this.dashboardsById.forEach((d) => { const panels = d.attributes.panels; const matchingPanels = this.getPanelsByField(fields, panels); @@ -249,15 +274,13 @@ export class RelatedDashboardsClient { } } - getRuleQueryIndex(): string | null { - if (!this.alert) { - throw new Error('Alert not found. Could not get the rule query index.'); - } - const index = this.alert.getRuleQueryIndex(); + private getRuleQueryIndex(): string | null { + const alert = this.checkAlert(); + const index = alert.getRuleQueryIndex(); return index; } - getLensVizIndices(lensAttr: LensAttributes): Set { + private getLensVizIndices(lensAttr: LensAttributes): Set { const indices = new Set( lensAttr.references .filter((r) => r.name.match(`indexpattern`)) @@ -266,7 +289,7 @@ export class RelatedDashboardsClient { return indices; } - getLensVizFields(lensAttr: LensAttributes): Set { + private getLensVizFields(lensAttr: LensAttributes): Set { const fields = new Set(); const dataSourceLayers = lensAttr.state.datasourceStates.formBased?.layers || {}; Object.values(dataSourceLayers).forEach((ds) => { @@ -284,10 +307,43 @@ export class RelatedDashboardsClient { return fields; } - getMatchingFields(dashboard: RelatedDashboard): string[] { + private async getLinkedDashboards(): Promise { + const alert = this.checkAlert(); + const ruleId = alert.getRuleId(); + if (!ruleId) { + throw new Error( + `Alert with id ${this.alertId} does not have a rule ID. Could not fetch linked dashboards.` + ); + } + const rule = await this.alertsClient.getRuleById(ruleId); + if (!rule) { + throw new Error( + `Rule with id ${ruleId} not found. Could not fetch linked dashboards for alert with id ${this.alertId}.` + ); + } + const linkedDashboardsArtifacts = rule.artifacts?.dashboards || []; + const linkedDashboards = await this.getLinkedDashboardsByIds( + linkedDashboardsArtifacts.map((d) => d.id) + ); + return linkedDashboards; + } + + private async getLinkedDashboardsByIds(ids: string[]): Promise { + const dashboardsResponse = await Promise.all(ids.map((id) => this.dashboardClient.get(id))); + const linkedDashboards: Dashboard[] = dashboardsResponse.map((d) => { + return d.result.item; + }); + return linkedDashboards.map((d) => ({ + id: d.id, + title: d.attributes.title, + matchedBy: { linked: true }, + })); + } + + private getMatchingFields(dashboard: RelatedDashboard): string[] { const matchingFields = new Set(); // grab all the top level arrays from the matchedBy object via Object.values - Object.values(dashboard.matchedBy).forEach((match) => { + Object.values(omit(dashboard.matchedBy, 'linked')).forEach((match) => { // add the values of each array to the matchingFields set match.forEach((value) => { matchingFields.add(value); @@ -296,13 +352,9 @@ export class RelatedDashboardsClient { return Array.from(matchingFields); } - getScore(dashboard: RelatedDashboard): number { - if (!this.alert) { - throw new Error( - `Alert with id ${this.alertId} not found. Could not compute the relevance score for suggested dashboard.` - ); - } - const allRelevantFields = this.alert.getAllRelevantFields(); + private getScore(dashboard: RelatedDashboard): number { + const alert = this.checkAlert(); + const allRelevantFields = alert.getAllRelevantFields(); const index = this.getRuleQueryIndex(); const setA = new Set([...allRelevantFields, ...(index ? [index] : [])]); const setB = new Set(this.getMatchingFields(dashboard));