[Incident Management] add recommended dashboards api (#213287)

## Summary

Adds a basic api for finding suggested dashboards by alert.

This internal API is currently not in use anywhere.

This api finds suggested dashboards in two ways:
1. Finding dashboards with lens visualizations that query against the
same data view
2. Finding dashboards with lens visualizations that utilize fields in
the rule configuration, or alert data.

These are two naive approaches to finding suggested dashboards. These
heuristics will be improved over time and incorporate more sophisticated
approaches that have been explored by numerous engineers across
Observability.

## Testing

A basic api integration test is included that covers matching dashboards
by index and field, across spaces. As we develop this feature further
over time more fine grain tests will be added

### Manual Testing

1. Run
```
node x-pack/scripts/data_forge.js --events-per-cycle 200 --lookback now-1h --ephemeral-project-ids 10 --dataset fake_stack --install-kibana-assets --kibana-url http://localhost:5601 --event-template bad
```
2. Save the file as a .ndjson file and import it via saved objects
import
https://p.elstc.co/paste/3BWKIHLU#f0WfGYx7G9DhWy88yDXhaEXTX16Fn+ovDcomNIx6E3a
3. Navigate to the alert details page, you should start to see a lot of
alerts
4. Click on one, and copy the alert id
5. Navigate to console and paste `GET
kbn:/internal/observability/alerts/suggested_dashboards?alertId=[YOUR_ALERT_ID]`
to see the recommended dashboards

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Nick Peihl <nickpeihl@gmail.com>
This commit is contained in:
Dominique Clarke 2025-03-17 15:13:04 -04:00 committed by GitHub
parent da7e44988d
commit 71b7458767
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1955 additions and 8 deletions

2
.github/CODEOWNERS vendored
View file

@ -933,6 +933,7 @@ x-pack/solutions/observability/packages/get-padded-alert-time-range-util @elasti
x-pack/solutions/observability/packages/kbn-alerts-grouping @elastic/response-ops
x-pack/solutions/observability/packages/kbn-custom-integrations @elastic/obs-ux-logs-team
x-pack/solutions/observability/packages/kbn-investigation-shared @elastic/obs-ux-management-team
x-pack/solutions/observability/packages/kbn-observability-schema @elastic/obs-ux-management-team
x-pack/solutions/observability/packages/kbn-scout-oblt @elastic/appex-qa
x-pack/solutions/observability/packages/observability-ai/observability-ai-common @elastic/obs-ai-assistant
x-pack/solutions/observability/packages/observability-ai/observability-ai-server @elastic/obs-ai-assistant
@ -1390,6 +1391,7 @@ packages/kbn-monaco/src/esql @elastic/kibana-esql
/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/ @elastic/obs-ux-management-team
/x-pack/test/api_integration/deployment_agnostic/services/synthetics_monitors @elastic/obs-ux-management-team
/x-pack/test/api_integration/deployment_agnostic/services/synthetics_private_location @elastic/obs-ux-management-team
/x-pack/test/api_integration/deployment_agnostic/apis/observability/incident_management/ @elastic/obs-ux-management-team
/x-pack/test/functional/page_objects/alert_controls.ts @elastic/obs-ux-management-team
# Elastic Stack Monitoring

View file

@ -715,6 +715,7 @@
"@kbn/observability-logs-explorer-plugin": "link:x-pack/solutions/observability/plugins/observability_logs_explorer",
"@kbn/observability-onboarding-plugin": "link:x-pack/solutions/observability/plugins/observability_onboarding",
"@kbn/observability-plugin": "link:x-pack/solutions/observability/plugins/observability",
"@kbn/observability-schema": "link:x-pack/solutions/observability/packages/kbn-observability-schema",
"@kbn/observability-shared-plugin": "link:x-pack/solutions/observability/plugins/observability_shared",
"@kbn/observability-utils-browser": "link:x-pack/solutions/observability/packages/utils-browser",
"@kbn/observability-utils-common": "link:x-pack/solutions/observability/packages/utils-common",

View file

@ -134,7 +134,7 @@ const namespaces = {
ALERT_RULE_NAMESPACE,
};
const fields = {
export const fields = {
ALERT_ACTION_GROUP,
ALERT_CASE_IDS,
ALERT_DURATION,

View file

@ -7,5 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { customThresholdParamsSchema } from './latest';
export { customThresholdParamsSchema, type CustomThresholdParams } from './latest';
export { customThresholdParamsSchema as customThresholdParamsSchemaV1 } from './v1';

View file

@ -6,7 +6,7 @@
* 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 { TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
import { COMPARATORS } from '@kbn/alerting-comparators';
import { dataViewSpecSchema } from '../common';
@ -85,3 +85,5 @@ export const customThresholdParamsSchema = schema.object(
},
{ unknowns: 'allow' }
);
export type CustomThresholdParams = TypeOf<typeof customThresholdParamsSchema>;

View file

@ -11,6 +11,7 @@ import type { Version } from '@kbn/object-versioning';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { FavoritesSetup } from '@kbn/content-management-favorites-server';
import type { CoreApi, StorageContextGetTransformFn } from './core';
export type { IContentClient } from './content_client/types';
export interface ContentManagementServerSetupDependencies {
usageCollection?: UsageCollectionSetup;

View file

@ -26,7 +26,7 @@ export async function plugin(initializerContext: PluginInitializerContext) {
}
export type { DashboardPluginSetup, DashboardPluginStart } from './types';
export type { DashboardAttributes } from './content_management';
export type { DashboardAttributes, DashboardPanel } from './content_management';
export type { DashboardSavedObjectAttributes } from './dashboard_saved_object';
export { PUBLIC_API_PATH } from './api/constants';

View file

@ -1358,6 +1358,8 @@
"@kbn/observability-onboarding-plugin/*": ["x-pack/solutions/observability/plugins/observability_onboarding/*"],
"@kbn/observability-plugin": ["x-pack/solutions/observability/plugins/observability"],
"@kbn/observability-plugin/*": ["x-pack/solutions/observability/plugins/observability/*"],
"@kbn/observability-schema": ["x-pack/solutions/observability/packages/kbn-observability-schema"],
"@kbn/observability-schema/*": ["x-pack/solutions/observability/packages/kbn-observability-schema/*"],
"@kbn/observability-shared-plugin": ["x-pack/solutions/observability/plugins/observability_shared"],
"@kbn/observability-shared-plugin/*": ["x-pack/solutions/observability/plugins/observability_shared/*"],
"@kbn/observability-synthetics-test-data": ["x-pack/solutions/observability/packages/synthetics-test-data"],

View file

@ -0,0 +1,3 @@
# @kbn/observability-schema
Provides schema for generic Observability apis

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './related_dashboards/latest';

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/solutions/observability/packages/kbn-observability-schema'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/observability-schema",
"owner": "@elastic/obs-ux-management-team",
"group": "observability",
"visibility": "private",
}

View file

@ -0,0 +1,8 @@
{
"name": "@kbn/observability-schema",
"descriptio": "Utils to generate observability synthetics test data",
"author": "UX Management",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
export { type RelevantPanel, relevantPanelSchema } from './schema/relevant_panel/v1';
export { type RelatedDashboard, relatedDashboardSchema } from './schema/related_dashboard/v1';
export {
type GetRelatedDashboardsResponse,
getRelatedDashboardsResponseSchema,
getRelatedDashboardsParamsSchema,
} from './rest_specs/get_related_dashboards/v1';

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export {
type GetRelatedDashboardsResponse,
getRelatedDashboardsResponseSchema,
getRelatedDashboardsParamsSchema,
} from './v1';

View file

@ -0,0 +1,22 @@
/*
* 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 { z } from '@kbn/zod';
import { relatedDashboardSchema } from '../../schema/related_dashboard/v1';
export const getRelatedDashboardsParamsSchema = z.object({
query: z.object({
alertId: z.string(),
}),
});
export const getRelatedDashboardsResponseSchema = z.object({
suggestedDashboards: z.array(relatedDashboardSchema),
linkedDashboards: z.array(relatedDashboardSchema),
});
export type GetRelatedDashboardsResponse = z.output<typeof getRelatedDashboardsResponseSchema>;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { type RelatedDashboard, relatedDashboardSchema } from './v1';

View file

@ -0,0 +1,22 @@
/*
* 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 { 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(),
}),
relevantPanelCount: z.number(),
relevantPanels: z.array(relevantPanelSchema),
});
export type RelatedDashboard = z.output<typeof relatedDashboardSchema>;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { type RelevantPanel, relevantPanelSchema } from './v1';

View file

@ -0,0 +1,23 @@
/*
* 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 { z } from '@kbn/zod';
export const relevantPanelSchema = z.object({
panel: z.object({
panelIndex: z.string(),
type: z.string(),
panelConfig: z.record(z.string(), z.any()),
title: z.string().optional(),
}),
matchedBy: z.object({
fields: z.array(z.string()).optional(),
index: z.array(z.string()).optional(),
}),
});
export type RelevantPanel = z.output<typeof relevantPanelSchema>;

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/zod"
]
}

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export class AlertNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'AlertNotFoundError';
}
}

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { AlertingServerSetup, AlertingServerStart } from '@kbn/alerting-plugin/server';
import type { AlertingServerSetup, AlertingServerStart } from '@kbn/alerting-plugin/server';
import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server';
import type { DashboardPluginStart } from '@kbn/dashboard-plugin/server';
import {
createUICapabilities as createCasesUICapabilities,
getApiTags as getCasesApiTags,
@ -23,7 +25,10 @@ import { DISCOVER_APP_LOCATOR, type DiscoverAppLocatorParams } from '@kbn/discov
import { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server';
import { i18n } from '@kbn/i18n';
import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server';
import {
RuleRegistryPluginSetupContract,
RuleRegistryPluginStartContract,
} from '@kbn/rule-registry-plugin/server';
import { SharePluginSetup } from '@kbn/share-plugin/server';
import { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
@ -64,12 +69,15 @@ interface PluginSetup {
spaces?: SpacesPluginSetup;
usageCollection?: UsageCollectionSetup;
cloud?: CloudSetup;
contentManagement: ContentManagementServerSetup;
}
interface PluginStart {
alerting: AlertingServerStart;
spaces?: SpacesPluginStart;
dataViews: DataViewsServerPluginStart;
ruleRegistry: RuleRegistryPluginStartContract;
dashboard: DashboardPluginStart;
}
const alertingFeatures = OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES.map(
@ -192,6 +200,8 @@ export class ObservabilityPlugin
...plugins,
core,
},
dashboard: pluginStart.dashboard,
ruleRegistry: pluginStart.ruleRegistry,
dataViews: pluginStart.dataViews,
spaces: pluginStart.spaces,
ruleDataService,

View file

@ -0,0 +1,68 @@
/*
* 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 {
getRelatedDashboardsParamsSchema,
GetRelatedDashboardsResponse,
} from '@kbn/observability-schema';
import { IKibanaResponse } from '@kbn/core-http-server';
import type { SavedObjectsFindResult } from '@kbn/core/server';
import type { DashboardAttributes } from '@kbn/dashboard-plugin/server';
import { createObservabilityServerRoute } from '../create_observability_server_route';
import { RelatedDashboardsClient } from '../../services/related_dashboards_client';
import { InvestigateAlertsClient } from '../../services/investigate_alerts_client';
import { AlertNotFoundError } from '../../common/errors/alert_not_found_error';
const alertsDynamicDashboardSuggestions = createObservabilityServerRoute({
endpoint: 'GET /internal/observability/alerts/related_dashboards',
security: {
authz: {
enabled: false,
reason:
'This route is opted out from authorization because it is a wrapper around Saved Object client',
},
},
options: { access: 'internal' },
params: getRelatedDashboardsParamsSchema,
handler: async (services): Promise<GetRelatedDashboardsResponse | IKibanaResponse> => {
const { dependencies, params, request, response, context, logger } = services;
const { alertId } = params.query;
const { ruleRegistry, dashboard } = dependencies;
const { contentClient } = dashboard;
const dashboardClient = contentClient!.getForRequest<
SavedObjectsFindResult<DashboardAttributes>
>({
requestHandlerContext: context,
request,
version: 3,
});
const alertsClient = await ruleRegistry.getRacClientWithRequest(request);
const investigateAlertsClient = new InvestigateAlertsClient(alertsClient);
const dashboardParser = new RelatedDashboardsClient(
logger,
dashboardClient,
investigateAlertsClient,
alertId
);
try {
const { suggestedDashboards } = await dashboardParser.fetchSuggestedDashboards();
return {
suggestedDashboards,
linkedDashboards: [],
};
} catch (e) {
if (e instanceof AlertNotFoundError) {
return response.badRequest({ body: { message: e.message } });
}
throw e;
}
},
});
export const alertsSuggestedDashboardRepository = alertsDynamicDashboardSuggestions;

View file

@ -9,11 +9,13 @@ import { EndpointOf } from '@kbn/server-route-repository';
import { ObservabilityConfig } from '..';
import { aiAssistantRouteRepository } from './assistant/route';
import { rulesRouteRepository } from './rules/route';
import { alertsSuggestedDashboardRepository } from './alerts/route';
export function getObservabilityServerRouteRepository(config: ObservabilityConfig) {
const repository = {
...aiAssistantRouteRepository,
...rulesRouteRepository,
...alertsSuggestedDashboardRepository,
};
return repository;
}

View file

@ -7,7 +7,11 @@
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { CoreSetup, KibanaRequest, Logger } from '@kbn/core/server';
import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server';
import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server';
import type { DashboardPluginStart } from '@kbn/dashboard-plugin/server';
import {
RuleDataPluginService,
RuleRegistryPluginStartContract,
} from '@kbn/rule-registry-plugin/server';
import { registerRoutes as registerServerRoutes } from '@kbn/server-route-repository';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import { AlertDetailsContextualInsightsService } from '../services';
@ -28,6 +32,8 @@ export interface RegisterRoutesDependencies {
dataViews: DataViewsServerPluginStart;
spaces?: SpacesPluginStart;
ruleDataService: RuleDataPluginService;
ruleRegistry: RuleRegistryPluginStartContract;
dashboard: DashboardPluginStart;
assistant: {
alertDetailsContextualInsightsService: AlertDetailsContextualInsightsService;
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { EndpointOf, ReturnOf, ServerRouteRepository } from '@kbn/server-route-repository';
import { KibanaRequest, Logger } from '@kbn/core/server';
import { KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server';
import {
ObservabilityServerRouteRepository,
@ -21,6 +21,7 @@ export interface ObservabilityRouteHandlerResources {
dependencies: RegisterRoutesDependencies;
logger: Logger;
request: KibanaRequest;
response: KibanaResponseFactory;
}
export interface ObservabilityRouteCreateOptions {

View file

@ -0,0 +1,76 @@
/*
* 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 { omit } from 'lodash';
import { CustomThresholdParams } from '@kbn/response-ops-rule-params/custom_threshold';
import {
ALERT_RULE_PARAMETERS,
ALERT_RULE_TYPE_ID,
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
fields as TECHNICAL_ALERT_FIELDS,
} from '@kbn/rule-data-utils';
export class AlertData {
constructor(private alert: any) {}
getRuleParameters() {
return this.alert[ALERT_RULE_PARAMETERS];
}
getRelevantRuleFields(): Set<string> {
const ruleParameters = this.getRuleParameters();
const relevantFields = new Set<string>();
if (!ruleParameters) {
throw new Error('No rule parameters found');
}
switch (this.getRuleTypeId()) {
case OBSERVABILITY_THRESHOLD_RULE_TYPE_ID:
const customThresholdParams = ruleParameters as CustomThresholdParams;
const metrics = customThresholdParams.criteria[0].metrics;
metrics.forEach((metric) => {
relevantFields.add(metric.field);
});
return relevantFields;
default:
return relevantFields;
}
}
getRelevantAADFields(): string[] {
const ignoredFields = ['_index'];
const allKibanaFields = Object.keys(this.alert).filter((field) => field.startsWith('kibana.'));
const nonTechnicalFields = omit(this.alert, [
...Object.values(TECHNICAL_ALERT_FIELDS),
...allKibanaFields,
...ignoredFields,
]);
return Object.keys(nonTechnicalFields);
}
getAlertTags(): string[] {
return this.alert.tags || [];
}
getRuleQueryIndex() {
const ruleParameters = this.getRuleParameters();
const ruleTypeId = this.getRuleTypeId();
if (!ruleParameters) {
throw new Error('No rule parameters found');
}
switch (ruleTypeId) {
case OBSERVABILITY_THRESHOLD_RULE_TYPE_ID:
const customThresholdParams = ruleParameters as CustomThresholdParams;
return customThresholdParams.searchConfiguration.index;
default:
return '';
}
}
getRuleTypeId() {
return this.alert[ALERT_RULE_TYPE_ID];
}
}

View file

@ -0,0 +1,37 @@
/*
* 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 { OBSERVABILITY_RULE_TYPE_IDS } from '@kbn/rule-data-utils';
import { 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) {}
async getAlertById(alertId: string): Promise<AlertData> {
const indices = (await this.getAlertsIndices()) || [];
if (!indices.length) {
throw new Error('No alert indices exist');
}
try {
const alert = await this.alertsClient.get({
id: alertId,
index: indices.join(','),
});
return new AlertData(alert);
} catch (e) {
if (e.output.payload.statusCode === 404) {
throw new AlertNotFoundError(`Alert with id ${alertId} not found`);
}
throw e;
}
}
async getAlertsIndices() {
return await this.alertsClient.getAuthorizedAlertsIndices(OBSERVABILITY_RULE_TYPE_IDS);
}
}

View file

@ -0,0 +1,267 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import type { SavedObjectsFindResult } from '@kbn/core/server';
import { IContentClient } from '@kbn/content-management-plugin/server/types';
import type {
FieldBasedIndexPatternColumn,
GenericIndexPatternColumn,
} from '@kbn/lens-plugin/public';
import type { Logger } from '@kbn/core/server';
import type { LensAttributes } from '@kbn/lens-embeddable-utils';
import type { RelevantPanel, RelatedDashboard } from '@kbn/observability-schema';
import type { DashboardAttributes, DashboardPanel } from '@kbn/dashboard-plugin/server';
import type { InvestigateAlertsClient } from './investigate_alerts_client';
import type { AlertData } from './alert_data';
type Dashboard = SavedObjectsFindResult<DashboardAttributes>;
export class RelatedDashboardsClient {
private dashboardsById = new Map<string, Dashboard>();
private alert: AlertData | null = null;
constructor(
private logger: Logger,
private dashboardClient: IContentClient<Dashboard>,
private alertsClient: InvestigateAlertsClient,
private alertId: string
) {}
async fetchSuggestedDashboards(): Promise<{ suggestedDashboards: RelatedDashboard[] }> {
const allRelatedDashboards = new Set<RelatedDashboard>();
const relevantDashboardsById = new Map<string, RelatedDashboard>();
const [alert] = await Promise.all([
this.alertsClient.getAlertById(this.alertId),
this.fetchAllDashboards(),
]);
this.alert = alert;
if (!this.alert) {
return { suggestedDashboards: [] };
}
const index = await this.getRuleQueryIndex();
const relevantRuleFields = this.alert.getRelevantRuleFields();
const relevantAlertFields = this.alert.getRelevantAADFields();
const allRelevantFields = new Set([...relevantRuleFields, ...relevantAlertFields]);
if (index) {
const { dashboards } = this.getDashboardsByIndex(index);
dashboards.forEach((dashboard) => allRelatedDashboards.add(dashboard));
}
if (allRelevantFields.size > 0) {
const { dashboards } = this.getDashboardsByField(Array.from(allRelevantFields));
dashboards.forEach((dashboard) => allRelatedDashboards.add(dashboard));
}
allRelatedDashboards.forEach((dashboard) => {
const dedupedPanels = this.dedupePanels([
...(relevantDashboardsById.get(dashboard.id)?.relevantPanels || []),
...dashboard.relevantPanels,
]);
const relevantPanelCount = dedupedPanels.length;
relevantDashboardsById.set(dashboard.id, {
...dashboard,
matchedBy: {
...relevantDashboardsById.get(dashboard.id)?.matchedBy,
...dashboard.matchedBy,
},
relevantPanelCount,
relevantPanels: dedupedPanels,
});
});
return { suggestedDashboards: Array.from(relevantDashboardsById.values()) };
}
async fetchDashboards(page: number) {
const perPage = 1000;
const dashboards = await this.dashboardClient.search(
{ limit: perPage, cursor: `${page}` },
{ spaces: ['*'] }
);
const {
result: { hits },
} = dashboards;
hits.forEach((dashboard: Dashboard) => {
this.dashboardsById.set(dashboard.id, dashboard);
});
const fetchedUntil = (page - 1) * perPage + dashboards.result.hits.length;
if (dashboards.result.pagination.total <= fetchedUntil) {
return;
}
await this.fetchDashboards(page + 1);
}
async fetchAllDashboards() {
await this.fetchDashboards(1);
}
getDashboardsByIndex(index: string): {
dashboards: RelatedDashboard[];
} {
const relevantDashboards: RelatedDashboard[] = [];
this.dashboardsById.forEach((d) => {
const panels = d.attributes.panels;
const matchingPanels = this.getPanelsByIndex(index, panels);
if (matchingPanels.length > 0) {
this.logger.info(
() => `Found ${matchingPanels.length} panel(s) in dashboard ${d.id} using index ${index}`
);
relevantDashboards.push({
id: d.id,
title: d.attributes.title,
matchedBy: { index: [index] },
relevantPanelCount: matchingPanels.length,
relevantPanels: matchingPanels.map((p) => ({
panel: {
panelIndex: p.panelIndex || uuidv4(),
type: p.type,
panelConfig: p.panelConfig,
title: p.title,
},
matchedBy: { index: [index] },
})),
});
}
});
return { dashboards: relevantDashboards };
}
dedupePanels(panels: RelevantPanel[]): RelevantPanel[] {
const uniquePanels = new Map<string, RelevantPanel>();
panels.forEach((p) => {
uniquePanels.set(p.panel.panelIndex, {
...p,
matchedBy: { ...uniquePanels.get(p.panel.panelIndex)?.matchedBy, ...p.matchedBy },
});
});
return Array.from(uniquePanels.values());
}
getDashboardsByField(fields: string[]): {
dashboards: RelatedDashboard[];
} {
const relevantDashboards: RelatedDashboard[] = [];
this.dashboardsById.forEach((d) => {
const panels = d.attributes.panels;
const matchingPanels = this.getPanelsByField(fields, panels);
const allMatchingFields = new Set(
matchingPanels.map((p) => Array.from(p.matchingFields)).flat()
);
if (matchingPanels.length > 0) {
this.logger.info(
() =>
`Found ${matchingPanels.length} panel(s) in dashboard ${
d.id
} using field(s) ${Array.from(allMatchingFields).toString()}`
);
relevantDashboards.push({
id: d.id,
title: d.attributes.title,
matchedBy: { fields: Array.from(allMatchingFields) },
relevantPanelCount: matchingPanels.length,
relevantPanels: matchingPanels.map((p) => ({
panel: {
panelIndex: p.panel.panelIndex || uuidv4(),
type: p.panel.type,
panelConfig: p.panel.panelConfig,
title: p.panel.title,
},
matchedBy: { fields: Array.from(p.matchingFields) },
})),
});
}
});
return { dashboards: relevantDashboards };
}
getPanelsByIndex(index: string, panels: DashboardPanel[]): DashboardPanel[] {
const panelsByIndex = panels.filter((p) => {
const panelIndices = this.getPanelIndices(p);
return panelIndices.has(index);
});
return panelsByIndex;
}
getPanelsByField(
fields: string[],
panels: DashboardPanel[]
): Array<{ matchingFields: Set<string>; panel: DashboardPanel }> {
const panelsByField = panels.reduce((acc, p) => {
const panelFields = this.getPanelFields(p);
const matchingFields = fields.filter((f) => panelFields.has(f));
if (matchingFields.length) {
acc.push({ matchingFields: new Set(matchingFields), panel: p });
}
return acc;
}, [] as Array<{ matchingFields: Set<string>; panel: DashboardPanel }>);
return panelsByField;
}
getPanelIndices(panel: DashboardPanel): Set<string> {
const indices = new Set<string>();
switch (panel.type) {
case 'lens':
const lensAttr = panel.panelConfig.attributes as unknown as LensAttributes;
if (!lensAttr) {
return indices;
}
const lensIndices = this.getLensVizIndices(lensAttr);
return lensIndices;
default:
return indices;
}
}
getPanelFields(panel: DashboardPanel): Set<string> {
const fields = new Set<string>();
switch (panel.type) {
case 'lens':
const lensAttr = panel.panelConfig.attributes as unknown as LensAttributes;
const lensFields = this.getLensVizFields(lensAttr);
return lensFields;
default:
return fields;
}
}
async getRuleQueryIndex(): Promise<string> {
if (!this.alert) {
throw new Error('Alert not found. Could not get the rule query index.');
}
const index = this.alert.getRuleQueryIndex();
return typeof index === 'string' ? index : index.id || '';
}
getLensVizIndices(lensAttr: LensAttributes): Set<string> {
const indices = new Set(
lensAttr.references
.filter((r) => r.name.match(`indexpattern`))
.map((reference) => reference.id)
);
if (indices.size === 0) {
throw new Error('No index patterns found in lens visualization');
}
return indices;
}
getLensVizFields(lensAttr: LensAttributes): Set<string> {
const fields = new Set<string>();
const dataSourceLayers = lensAttr.state.datasourceStates.formBased?.layers || {};
Object.values(dataSourceLayers).forEach((ds) => {
const columns = ds.columns;
Object.values(columns).forEach((col) => {
const hasSourceField = (
c: FieldBasedIndexPatternColumn | GenericIndexPatternColumn
): c is FieldBasedIndexPatternColumn =>
(c as FieldBasedIndexPatternColumn).sourceField !== undefined;
if (hasSourceField(col)) {
fields.add(col.sourceField);
}
});
});
return fields;
}
}

View file

@ -31,6 +31,7 @@
"@kbn/inspector-plugin",
"@kbn/shared-ux-page-kibana-template",
"@kbn/observability-ai-assistant-plugin",
"@kbn/observability-schema",
"@kbn/shared-ux-router",
"@kbn/kibana-react-plugin",
"@kbn/react-kibana-context-render",
@ -117,6 +118,7 @@
"@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"

View file

@ -0,0 +1,14 @@
/*
* 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 { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) {
describe('Incident Management', () => {
loadTestFile(require.resolve('./suggested_dashboards'));
});
}

View file

@ -24,5 +24,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext)
loadTestFile(require.resolve('../../apis/observability/ai_assistant'));
loadTestFile(require.resolve('../../apis/observability/streams'));
loadTestFile(require.resolve('../../apis/observability/onboarding'));
loadTestFile(require.resolve('../../apis/observability/incident_management'));
});
}

View file

@ -18,5 +18,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext)
loadTestFile(require.resolve('../../apis/observability/ai_assistant'));
loadTestFile(require.resolve('../../apis/observability/streams'));
loadTestFile(require.resolve('../../apis/observability/onboarding'));
loadTestFile(require.resolve('../../apis/observability/incident_management'));
});
}

View file

@ -6394,6 +6394,10 @@
version "0.0.0"
uid ""
"@kbn/observability-schema@link:x-pack/solutions/observability/packages/kbn-observability-schema":
version "0.0.0"
uid ""
"@kbn/observability-shared-plugin@link:x-pack/solutions/observability/plugins/observability_shared":
version "0.0.0"
uid ""