mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Incident Management] [Suggested dashboards] Deduplicated linked dashboards from list of linked dashboards (#221972)
## Summary Resolves https://github.com/elastic/kibana/issues/212801 Removes already linked dashboards from the list of suggested dashboards Also has the side effect of returning the linked dashboards from the related dashboards api, which can be used to render the linked dashboards list along with the suggested dashboards, rather than calling a separate API from the client. --------- Co-authored-by: Justin Kambic <jk@elastic.co>
This commit is contained in:
parent
2c5d5a49d7
commit
33825663e9
7 changed files with 409 additions and 68 deletions
|
@ -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,
|
||||
|
|
|
@ -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<typeof relatedDashboardSchema>;
|
||||
export type SuggestedDashboard = z.output<typeof suggestedDashboardSchema>;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<ReturnType<AlertsClient['get']>>) {}
|
||||
|
||||
getRuleParameters() {
|
||||
return this.alert[ALERT_RULE_PARAMETERS];
|
||||
}
|
||||
|
||||
getRuleId() {
|
||||
return this.alert[ALERT_RULE_UUID];
|
||||
}
|
||||
|
||||
getRelevantRuleFields(): Set<string> {
|
||||
const ruleParameters = this.getRuleParameters();
|
||||
const relevantFields = new Set<string>();
|
||||
|
@ -81,7 +87,7 @@ export class AlertData {
|
|||
}
|
||||
}
|
||||
|
||||
getRuleTypeId() {
|
||||
getRuleTypeId(): string | undefined {
|
||||
return this.alert[ALERT_RULE_TYPE_ID];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AlertData> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -17,6 +17,11 @@ describe('RelatedDashboardsClient', () => {
|
|||
let alertsClient: jest.Mocked<InvestigateAlertsClient>;
|
||||
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<IContentClient<any>>;
|
||||
|
||||
alertsClient = {
|
||||
getAlertById: jest.fn(),
|
||||
getRuleById: jest.fn().mockResolvedValue({
|
||||
artifacts: {
|
||||
dashboards: [],
|
||||
},
|
||||
}),
|
||||
} as unknown as jest.Mocked<InvestigateAlertsClient>;
|
||||
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<DashboardAttributes>;
|
||||
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<RelatedDashboard>();
|
||||
const relevantDashboardsById = new Map<string, RelatedDashboard>();
|
||||
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<SuggestedDashboard[]> {
|
||||
const alert = this.checkAlert();
|
||||
const allSuggestedDashboards = new Set<SuggestedDashboard>();
|
||||
const relevantDashboardsById = new Map<string, SuggestedDashboard>();
|
||||
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<string, RelevantPanel>();
|
||||
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<string> {
|
||||
private getLensVizIndices(lensAttr: LensAttributes): Set<string> {
|
||||
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<string> {
|
||||
private getLensVizFields(lensAttr: LensAttributes): Set<string> {
|
||||
const fields = new Set<string>();
|
||||
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<RelatedDashboard[]> {
|
||||
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<RelatedDashboard[]> {
|
||||
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<string>();
|
||||
// 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<string>([...allRelevantFields, ...(index ? [index] : [])]);
|
||||
const setB = new Set<string>(this.getMatchingFields(dashboard));
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue