Skip to content

Commit

Permalink
Add inventory locator (#183418)
Browse files Browse the repository at this point in the history
Relates to #176667 

## Summary

This PR adds an inventory locator and replaces the old redirect logic in
`RedirectToInventory`

## Testing

To simulate the usage of `link-to` use similar params to what alerts
link is using, for example:

`/app/metrics/link-to/inventory?customMetric=&metric=(type%3Acpu)&nodeType=pod&timestamp=1715703274459`
Compare the pages with the edge cluster to make sure that it looks the
same as before
  • Loading branch information
jennypavlova authored May 17, 2024
1 parent 148eeec commit 85639f6
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouteComponentProps> = ({ location }) => {
const parsedQueryString = parseQueryString(location.search);

const inventoryQueryString = QUERY_STRING_TEMPLATE.replace(
/{(\w+)}/g,
(_, key) => parsedQueryString[key] || ''
);

return <Redirect to={'/inventory' + inventoryQueryString} />;
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<string, string> {
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<string, string>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InventoryLocatorParams>;

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<InventoryLocatorParams> {
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 : {},
};
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<string, string | undefined>, 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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +73,7 @@ interface ObservabilitySharedLocators {
assetDetailsLocator: AssetDetailsLocator;
assetDetailsFlyoutLocator: AssetDetailsFlyoutLocator;
hostsLocator: HostsLocator;
inventoryLocator: InventoryLocator;
};
profiling: {
flamegraphLocator: FlamegraphLocator;
Expand Down Expand Up @@ -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()),
Expand Down

0 comments on commit 85639f6

Please sign in to comment.