diff --git a/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts b/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts index ff6b6d2c752ce..a1f0da3531215 100644 --- a/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts +++ b/x-pack/plugins/observability_solution/infra/common/alerting/metrics/alert_link.ts @@ -9,7 +9,7 @@ import { ALERT_RULE_PARAMETERS, TIMESTAMP } from '@kbn/rule-data-utils'; import { encode } from '@kbn/rison'; import { stringify } from 'query-string'; import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_technical_fields'; -import { InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; +import type { InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; import { fifteenMinutesInMilliseconds, HOST_FIELD, @@ -59,37 +59,36 @@ export const getInventoryViewInAppUrl = ( if (nodeType) { if (hostName) { return getLinkToHostDetails({ hostName, timestamp: inventoryFields[TIMESTAMP] }); - } else { - const linkToParams = { - nodeType: inventoryFields[nodeTypeField][0], - timestamp: Date.parse(inventoryFields[TIMESTAMP]), - customMetric: '', - metric: '', - }; + } + const linkToParams = { + nodeType: inventoryFields[nodeTypeField][0], + timestamp: Date.parse(inventoryFields[TIMESTAMP]), + customMetric: '', + metric: '', + }; - // We always pick the first criteria metric for the URL - const criteriaMetric = inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.metric`][0]; - if (criteriaMetric === 'custom') { - const criteriaCustomMetricId = - inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.id`][0]; - const criteriaCustomMetricAggregation = - inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.aggregation`][0]; - const criteriaCustomMetricField = - inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.field`][0]; + // We always pick the first criteria metric for the URL + const criteriaMetric = inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.metric`][0]; + if (criteriaMetric === 'custom') { + const criteriaCustomMetricId = + inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.id`][0]; + const criteriaCustomMetricAggregation = + inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.aggregation`][0]; + const criteriaCustomMetricField = + inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.field`][0]; - const customMetric = encode({ - id: criteriaCustomMetricId, - type: 'custom', - field: criteriaCustomMetricField, - aggregation: criteriaCustomMetricAggregation, - }); - linkToParams.customMetric = customMetric; - linkToParams.metric = customMetric; - } else { - linkToParams.metric = encode({ type: criteriaMetric }); - } - return `${LINK_TO_INVENTORY}?${stringify(linkToParams)}`; + const customMetric = encode({ + id: criteriaCustomMetricId, + type: 'custom', + field: criteriaCustomMetricField, + aggregation: criteriaCustomMetricAggregation, + }); + linkToParams.customMetric = customMetric; + linkToParams.metric = customMetric; + } else { + linkToParams.metric = encode({ type: criteriaMetric }); } + return `${LINK_TO_INVENTORY}?${stringify(linkToParams)}`; } return LINK_TO_INVENTORY; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/link_to/redirect_to_inventory.tsx b/x-pack/plugins/observability_solution/infra/public/pages/link_to/redirect_to_inventory.tsx index 37ddbacf72488..c6d0bed51ed9c 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/link_to/redirect_to_inventory.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/link_to/redirect_to_inventory.tsx @@ -5,43 +5,32 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { parse } from 'query-string'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; - -// FIXME what would be the right way to build this query string? -const QUERY_STRING_TEMPLATE = - "?waffleFilter=(expression:'',kind:kuery)&waffleTime=(currentTime:{timestamp},isAutoReloading:!f)&waffleOptions=(accountId:'',autoBounds:!t,boundsOverride:(max:1,min:0),customMetrics:!({customMetric}),customOptions:!(),groupBy:!(),legend:(palette:cool,reverseColors:!f,steps:10),metric:{metric},nodeType:{nodeType},region:'',sort:(by:name,direction:desc),timelineOpen:!f,view:map)"; +import { RouteComponentProps } from 'react-router-dom'; +import type { SerializableRecord } from '@kbn/utility-types'; +import { INVENTORY_LOCATOR_ID } from '@kbn/observability-shared-plugin/public'; +import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; export const RedirectToInventory: React.FC = ({ location }) => { - const parsedQueryString = parseQueryString(location.search); - - const inventoryQueryString = QUERY_STRING_TEMPLATE.replace( - /{(\w+)}/g, - (_, key) => parsedQueryString[key] || '' - ); - - return ; + const { + services: { share }, + } = useKibanaContextForPlugin(); + const baseLocator = share.url.locators.get(INVENTORY_LOCATOR_ID); + + useEffect(() => { + const parsedQueryString = parse(location.search || '', { sort: false }); + const currentTime = parseFloat((parsedQueryString.timestamp ?? '') as string); + + baseLocator?.navigate({ + ...parsedQueryString, + waffleTime: { + currentTime, + isAutoReloading: false, + }, + state: location.state as SerializableRecord, + }); + }, [baseLocator, location.search, location.state]); + + return null; }; - -function parseQueryString(search: string): Record { - if (search.length === 0) { - return {}; - } - - const obj = parse(search.substring(1)); - - // Force all values into string. If they are empty don't create the keys - for (const key in obj) { - if (Object.hasOwnProperty.call(obj, key)) { - if (!obj[key]) { - delete obj[key]; - } - if (Array.isArray(obj.key)) { - obj[key] = obj[key]![0]; - } - } - } - - return obj as Record; -} diff --git a/x-pack/plugins/observability_solution/observability_shared/public/index.ts b/x-pack/plugins/observability_solution/observability_shared/public/index.ts index 93094c79369a9..a06f086d6588d 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/index.ts @@ -103,6 +103,7 @@ export { export { BottomBarActions } from './components/bottom_bar_actions/bottom_bar_actions'; export { FieldValueSelection, FieldValueSuggestions } from './components'; export { ASSET_DETAILS_FLYOUT_LOCATOR_ID } from './locators/infra/asset_details_flyout_locator'; +export { INVENTORY_LOCATOR_ID } from './locators/infra/inventory_locator'; export { ASSET_DETAILS_LOCATOR_ID, type AssetDetailsLocatorParams, diff --git a/x-pack/plugins/observability_solution/observability_shared/public/locators/infra/inventory_locator.ts b/x-pack/plugins/observability_solution/observability_shared/public/locators/infra/inventory_locator.ts new file mode 100644 index 0000000000000..ca6e997468b5b --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/public/locators/infra/inventory_locator.ts @@ -0,0 +1,94 @@ +/* + * 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 type { SerializableRecord } from '@kbn/utility-types'; +import rison from '@kbn/rison'; +import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/common'; +import querystring from 'querystring'; + +export type InventoryLocator = LocatorPublic; + +export interface InventoryLocatorParams extends SerializableRecord { + inventoryViewId?: string; + waffleFilter?: { + expression: string; + kind: string; + }; + waffleTime?: { + currentTime: number; + isAutoReloading: boolean; + }; + waffleOptions?: { + accountId: string; + autoBounds: boolean; + boundsOverride: { + max: number; + min: number; + }; + }; + customMetrics?: string; // encoded value + customOptions?: string; // encoded value + groupBy?: { field: string }; + legend?: { + palette: string; + reverseColors: boolean; + steps: number; + }; + metric: string; // encoded value + nodeType: string; + region?: string; + sort: { + by: string; + direction: 'desc' | 'async'; + }; + timelineOpen: boolean; + view: 'map' | 'table'; + state?: SerializableRecord; +} + +export const INVENTORY_LOCATOR_ID = 'INVENTORY_LOCATOR'; + +export class InventoryLocatorDefinition implements LocatorDefinition { + public readonly id = INVENTORY_LOCATOR_ID; + + public readonly getLocation = async (params: InventoryLocatorParams) => { + const paramsWithDefaults = { + waffleFilter: rison.encodeUnknown(params.waffleFilter ?? { kind: 'kuery', expression: '' }), + waffleTime: rison.encodeUnknown( + params.waffleTime ?? { + currentTime: new Date().getTime(), + isAutoReloading: false, + } + ), + waffleOptions: rison.encodeUnknown( + params.waffleOptions ?? { + accountId: '', + autoBounds: true, + boundsOverride: { max: 1, min: 0 }, + } + ), + customMetrics: params.customMetrics, + customOptions: params.customOptions, + groupBy: rison.encodeUnknown(params.groupBy ?? {}), + legend: rison.encodeUnknown( + params.legend ?? { palette: 'cool', reverseColors: false, steps: 10 } + ), + metric: params.metric, + nodeType: rison.encodeUnknown(params.nodeType), + region: rison.encodeUnknown(params.region ?? ''), + sort: rison.encodeUnknown(params.sort ?? { by: 'name', direction: 'desc' }), + timelineOpen: rison.encodeUnknown(params.timelineOpen ?? false), + view: rison.encodeUnknown(params.view ?? 'map'), + }; + + const queryStringParams = querystring.stringify(paramsWithDefaults); + return { + app: 'metrics', + path: `/inventory?${queryStringParams}`, + state: params.state ? params.state : {}, + }; + }; +} diff --git a/x-pack/plugins/observability_solution/observability_shared/public/locators/infra/locators.test.ts b/x-pack/plugins/observability_solution/observability_shared/public/locators/infra/locators.test.ts index ff02eff6dc76f..c7b5e16625e03 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/locators/infra/locators.test.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/locators/infra/locators.test.ts @@ -9,6 +9,8 @@ import rison from '@kbn/rison'; import { AssetDetailsLocatorDefinition } from './asset_details_locator'; import { AssetDetailsFlyoutLocatorDefinition } from './asset_details_flyout_locator'; import { HostsLocatorDefinition } from './hosts_locator'; +import { InventoryLocatorDefinition } from './inventory_locator'; +import querystring from 'querystring'; const setupAssetDetailsLocator = async () => { const assetDetailsLocator = new AssetDetailsLocatorDefinition(); @@ -28,6 +30,14 @@ const setupHostsLocator = async () => { }; }; +const setupInventoryLocator = async () => { + const inventoryLocator = new InventoryLocatorDefinition(); + + return { + inventoryLocator, + }; +}; + describe('Infra Locators', () => { describe('Asset Details Locator', () => { const params = { @@ -162,4 +172,59 @@ describe('Infra Locators', () => { expect(Object.keys(state)).toHaveLength(0); }); }); + + describe('Inventory Locator', () => { + const params = { + waffleFilter: { kind: 'kuery', expression: '' }, + waffleTime: { + currentTime: 1715688477985, + isAutoReloading: false, + }, + waffleOptions: { + accountId: '', + autoBounds: true, + boundsOverride: { max: 1, min: 0 }, + }, + customMetrics: undefined, + customOptions: undefined, + groupBy: { field: 'cloud.provider' }, + legend: { palette: 'cool', reverseColors: false, steps: 10 }, + metric: '(type:cpu)', + nodeType: 'host', + region: '', + sort: { by: 'name', direction: 'desc' as const }, + timelineOpen: false, + view: 'map' as const, + }; + + const expected = Object.keys(params).reduce((acc: Record, key) => { + acc[key] = + key === 'metric' || key === 'customOptions' || key === 'customMetrics' + ? params[key] + : rison.encodeUnknown(params[key as keyof typeof params]); + return acc; + }, {}); + + const queryStringParams = querystring.stringify(expected); + + it('should create a link to Inventory with no state', async () => { + const { inventoryLocator } = await setupInventoryLocator(); + const { app, path, state } = await inventoryLocator.getLocation(params); + + expect(app).toBe('metrics'); + expect(path).toBe(`/inventory?${queryStringParams}`); + expect(state).toBeDefined(); + expect(Object.keys(state)).toHaveLength(0); + }); + + it('should return correct structured url', async () => { + const { inventoryLocator } = await setupInventoryLocator(); + const { app, path, state } = await inventoryLocator.getLocation(params); + + expect(app).toBe('metrics'); + expect(path).toBe(`/inventory?${queryStringParams}`); + expect(state).toBeDefined(); + expect(Object.keys(state)).toHaveLength(0); + }); + }); }); diff --git a/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts b/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts index 625bdbaaeeb3a..97808e516f320 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts @@ -27,6 +27,10 @@ import { AssetDetailsLocatorDefinition, } from './locators/infra/asset_details_locator'; import { type HostsLocator, HostsLocatorDefinition } from './locators/infra/hosts_locator'; +import { + type InventoryLocator, + InventoryLocatorDefinition, +} from './locators/infra/inventory_locator'; import { type FlamegraphLocator, FlamegraphLocatorDefinition, @@ -69,6 +73,7 @@ interface ObservabilitySharedLocators { assetDetailsLocator: AssetDetailsLocator; assetDetailsFlyoutLocator: AssetDetailsFlyoutLocator; hostsLocator: HostsLocator; + inventoryLocator: InventoryLocator; }; profiling: { flamegraphLocator: FlamegraphLocator; @@ -137,6 +142,7 @@ export class ObservabilitySharedPlugin implements Plugin { new AssetDetailsFlyoutLocatorDefinition() ), hostsLocator: urlService.locators.create(new HostsLocatorDefinition()), + inventoryLocator: urlService.locators.create(new InventoryLocatorDefinition()), }, profiling: { flamegraphLocator: urlService.locators.create(new FlamegraphLocatorDefinition()),