Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add inventory locator #183418

Merged
merged 9 commits into from
May 17, 2024
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 =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😮

"?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,92 @@
/*
* 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';

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 || rison.encodeUnknown(''),
customOptions: params.customOptions || rison.encodeUnknown(''),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is needed rison.encodeUnknown('') needed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It adds '' (so &customMetrics=''&customOptions='') in the URL otherwise 🤷‍♀️ I think I can just leave the customMetrics: params.customMetrics to avoid that

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'),
};

return {
app: 'metrics',
path: `/inventory?waffleFilter=${paramsWithDefaults.waffleFilter}&waffleTime=${paramsWithDefaults.waffleTime}&waffleOptions=${paramsWithDefaults.waffleOptions}&customMetrics=${paramsWithDefaults.customMetrics}&customOptions=${paramsWithDefaults.customOptions}&groupBy=${paramsWithDefaults.groupBy}&legend=${paramsWithDefaults.legend}&metric=${paramsWithDefaults.metric}&nodeType=${paramsWithDefaults.nodeType}&region=${paramsWithDefaults.region}&sort=${paramsWithDefaults.sort}&timelineOpen=${paramsWithDefaults.timelineOpen}&view=${paramsWithDefaults.view}`,
state: params.state ? params.state : {},
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this work?

Suggested change
return {
app: 'metrics',
path: `/inventory?waffleFilter=${paramsWithDefaults.waffleFilter}&waffleTime=${paramsWithDefaults.waffleTime}&waffleOptions=${paramsWithDefaults.waffleOptions}&customMetrics=${paramsWithDefaults.customMetrics}&customOptions=${paramsWithDefaults.customOptions}&groupBy=${paramsWithDefaults.groupBy}&legend=${paramsWithDefaults.legend}&metric=${paramsWithDefaults.metric}&nodeType=${paramsWithDefaults.nodeType}&region=${paramsWithDefaults.region}&sort=${paramsWithDefaults.sort}&timelineOpen=${paramsWithDefaults.timelineOpen}&view=${paramsWithDefaults.view}`,
state: params.state ? params.state : {},
};
const queryStringParams = queryString.stringify(paramsWithDefaults);
return {
app: 'metrics',
path: `/inventory?${queryStringParams}`,
state: params.state ? params.state : {},
};

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, thanks 👍 I started with just 3 params then decided to support more in case we need them 😅

};
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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';

const setupAssetDetailsLocator = async () => {
const assetDetailsLocator = new AssetDetailsLocatorDefinition();
Expand All @@ -28,6 +29,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 +171,67 @@ 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: '',
customOptions: '',
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 waffleFilter = rison.encodeUnknown(params.waffleFilter);
const waffleTime = rison.encodeUnknown(params.waffleTime);
const waffleOptions = rison.encodeUnknown(params.waffleOptions);
const customMetrics = rison.encodeUnknown(params.customMetrics);
const customOptions = rison.encodeUnknown(params.customOptions);
const groupBy = rison.encodeUnknown(params.groupBy);
const legend = rison.encodeUnknown(params.legend);
const metric = params.metric;
const nodeType = rison.encodeUnknown(params.nodeType);
const region = rison.encodeUnknown(params.region);
const sort = rison.encodeUnknown(params.sort);
const timelineOpen = rison.encodeUnknown(params.timelineOpen);
const view = rison.encodeUnknown(params.view);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const waffleFilter = rison.encodeUnknown(params.waffleFilter);
const waffleTime = rison.encodeUnknown(params.waffleTime);
const waffleOptions = rison.encodeUnknown(params.waffleOptions);
const customMetrics = rison.encodeUnknown(params.customMetrics);
const customOptions = rison.encodeUnknown(params.customOptions);
const groupBy = rison.encodeUnknown(params.groupBy);
const legend = rison.encodeUnknown(params.legend);
const metric = params.metric;
const nodeType = rison.encodeUnknown(params.nodeType);
const region = rison.encodeUnknown(params.region);
const sort = rison.encodeUnknown(params.sort);
const timelineOpen = rison.encodeUnknown(params.timelineOpen);
const view = rison.encodeUnknown(params.view);
const expected = Object.keys(params).reduce((acc, key) => {
acc[key] = key === 'metric' ? params[key] : rison.encodeUnknown(params[key]);
return acc;
}, {});

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it makes sense, fixed! Sorry about those I was testing with different things and with/without encoding and forgot to remove and fix that.


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?waffleFilter=${waffleFilter}&waffleTime=${waffleTime}&waffleOptions=${waffleOptions}&customMetrics=${customMetrics}&customOptions=${customOptions}&groupBy=${groupBy}&legend=${legend}&metric=${metric}&nodeType=${nodeType}&region=${region}&sort=${sort}&timelineOpen=${timelineOpen}&view=${view}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should also work here

Suggested change
`/inventory?waffleFilter=${waffleFilter}&waffleTime=${waffleTime}&waffleOptions=${waffleOptions}&customMetrics=${customMetrics}&customOptions=${customOptions}&groupBy=${groupBy}&legend=${legend}&metric=${metric}&nodeType=${nodeType}&region=${region}&sort=${sort}&timelineOpen=${timelineOpen}&view=${view}`
`/inventory?queryString.stringify(expected);`

);
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?waffleFilter=${waffleFilter}&waffleTime=${waffleTime}&waffleOptions=${waffleOptions}&customMetrics=${customMetrics}&customOptions=${customOptions}&groupBy=${groupBy}&legend=${legend}&metric=${metric}&nodeType=${nodeType}&region=${region}&sort=${sort}&timelineOpen=${timelineOpen}&view=${view}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

);
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