[Infrastructure UI] Add strict payload validation to metrics_explorer_views endpoint (#160982)

closes [#157520](https://github.com/elastic/kibana/issues/157520)
## Summary

This PR adds strict payload validation to `metrics_explorer_views`
endpoint. This PR depends on this to be merged
https://github.com/elastic/kibana/pull/160852


### How to test

- Call the endpoint below trying to use invalid values. see
[here](https://github.com/elastic/kibana/pull/160982/files#diff-4573683b3b62cdf5f6426ec345b7ad6c7d6e6328237b213ca7519f686d8fa951R125-R131).

```bash
[POST|PUT] kbn:/api/infra/metrics_explorer_views
{
  "attributes": {
    "name": "Ad-hoc",
    "options": {
      "aggregation": "avg",
      "metrics": [
        {
          "aggregation": "avg",
          "field": "system.cpu.total.norm.pct",
          "color": "color0"
        },
      ],
      "source": "default",
      "groupBy": [
        "host.name"
      ]
    },
    "chartOptions": {
      "type": "line",
      "yAxisMode": "fromZero",
      "stack": false
    },
    "currentTimerange": {
      "from": "now-1h",
      "to": "now",
      "interval": ">=10s"
    }
  }
}
```

- Set up a local Kibana instance
- Navigate to `Infrastructure > Metrics Explorer`
- In the UI, use the Saved View feature and try different field
combinations

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2023-07-14 14:20:03 +02:00 committed by GitHub
parent a9786dfd6b
commit a0a83c1d3c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 432 additions and 311 deletions

View file

@ -4,9 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { nonEmptyStringRt } from '@kbn/io-ts-utils';
import * as rt from 'io-ts';
import { either } from 'fp-ts/Either';
import { metricsExplorerViewRT } from '../../../metrics_explorer_views';
export const METRICS_EXPLORER_VIEW_URL = '/api/infra/metrics_explorer_views';
export const METRICS_EXPLORER_VIEW_URL_ENTITY = `${METRICS_EXPLORER_VIEW_URL}/{metricsExplorerViewId}`;
@ -35,28 +35,6 @@ export const metricsExplorerViewRequestQueryRT = rt.partial({
export type MetricsExplorerViewRequestQuery = rt.TypeOf<typeof metricsExplorerViewRequestQueryRT>;
const metricsExplorerViewAttributesResponseRT = rt.intersection([
rt.strict({
name: nonEmptyStringRt,
isDefault: rt.boolean,
isStatic: rt.boolean,
}),
rt.UnknownRecord,
]);
const metricsExplorerViewResponseRT = rt.exact(
rt.intersection([
rt.type({
id: rt.string,
attributes: metricsExplorerViewAttributesResponseRT,
}),
rt.partial({
updatedAt: rt.number,
version: rt.string,
}),
])
);
export const metricsExplorerViewResponsePayloadRT = rt.type({
data: metricsExplorerViewResponseRT,
data: metricsExplorerViewRT,
});

View file

@ -5,15 +5,15 @@
* 2.0.
*/
import { nonEmptyStringRt } from '@kbn/io-ts-utils';
import * as rt from 'io-ts';
import {
metricsExplorerViewAttributesRT,
metricsExplorerViewRT,
} from '../../../metrics_explorer_views';
export const createMetricsExplorerViewAttributesRequestPayloadRT = rt.intersection([
rt.type({
name: nonEmptyStringRt,
}),
rt.UnknownRecord,
rt.exact(rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined })),
metricsExplorerViewAttributesRT,
rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined }),
]);
export type CreateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf<
@ -23,3 +23,5 @@ export type CreateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf<
export const createMetricsExplorerViewRequestPayloadRT = rt.type({
attributes: createMetricsExplorerViewAttributesRequestPayloadRT,
});
export type CreateMetricsExplorerViewResponsePayload = rt.TypeOf<typeof metricsExplorerViewRT>;

View file

@ -5,28 +5,13 @@
* 2.0.
*/
import { nonEmptyStringRt } from '@kbn/io-ts-utils';
import * as rt from 'io-ts';
export const findMetricsExplorerViewAttributesResponseRT = rt.strict({
name: nonEmptyStringRt,
isDefault: rt.boolean,
isStatic: rt.boolean,
});
const findMetricsExplorerViewResponseRT = rt.exact(
rt.intersection([
rt.type({
id: rt.string,
attributes: findMetricsExplorerViewAttributesResponseRT,
}),
rt.partial({
updatedAt: rt.number,
version: rt.string,
}),
])
);
import { singleMetricsExplorerViewRT } from '../../../metrics_explorer_views';
export const findMetricsExplorerViewResponsePayloadRT = rt.type({
data: rt.array(findMetricsExplorerViewResponseRT),
data: rt.array(singleMetricsExplorerViewRT),
});
export type FindMetricsExplorerViewResponsePayload = rt.TypeOf<
typeof findMetricsExplorerViewResponsePayloadRT
>;

View file

@ -6,7 +6,10 @@
*/
import * as rt from 'io-ts';
import { metricsExplorerViewRT } from '../../../metrics_explorer_views';
export const getMetricsExplorerViewRequestParamsRT = rt.type({
metricsExplorerViewId: rt.string,
});
export type GetMetricsExplorerViewResponsePayload = rt.TypeOf<typeof metricsExplorerViewRT>;

View file

@ -5,15 +5,15 @@
* 2.0.
*/
import { nonEmptyStringRt } from '@kbn/io-ts-utils';
import * as rt from 'io-ts';
import {
metricsExplorerViewAttributesRT,
metricsExplorerViewRT,
} from '../../../metrics_explorer_views';
export const updateMetricsExplorerViewAttributesRequestPayloadRT = rt.intersection([
rt.type({
name: nonEmptyStringRt,
}),
rt.UnknownRecord,
rt.exact(rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined })),
metricsExplorerViewAttributesRT,
rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined }),
]);
export type UpdateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf<
@ -23,3 +23,5 @@ export type UpdateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf<
export const updateMetricsExplorerViewRequestPayloadRT = rt.type({
attributes: updateMetricsExplorerViewAttributesRequestPayloadRT,
});
export type UpdateMetricsExplorerViewResponsePayload = rt.TypeOf<typeof metricsExplorerViewRT>;

View file

@ -7,7 +7,12 @@
import { i18n } from '@kbn/i18n';
import type { NonEmptyString } from '@kbn/io-ts-utils';
import type { MetricsExplorerViewAttributes } from './types';
import { Color } from '../color_palette';
import {
MetricsExplorerChartType,
MetricsExplorerViewAttributes,
MetricsExplorerYAxisMode,
} from './types';
export const staticMetricsExplorerViewId = '0';
@ -23,24 +28,24 @@ export const staticMetricsExplorerViewAttributes: MetricsExplorerViewAttributes
{
aggregation: 'avg',
field: 'system.cpu.total.norm.pct',
color: 'color0',
color: Color.color0,
},
{
aggregation: 'avg',
field: 'kubernetes.pod.cpu.usage.node.pct',
color: 'color1',
color: Color.color1,
},
{
aggregation: 'avg',
field: 'docker.cpu.total.pct',
color: 'color2',
color: Color.color2,
},
],
source: 'default',
},
chartOptions: {
type: 'line',
yAxisMode: 'fromZero',
type: MetricsExplorerChartType.line,
yAxisMode: MetricsExplorerYAxisMode.fromZero,
stack: false,
},
currentTimerange: {

View file

@ -5,19 +5,101 @@
* 2.0.
*/
import { nonEmptyStringRt } from '@kbn/io-ts-utils';
import { isoToEpochRt, nonEmptyStringRt } from '@kbn/io-ts-utils';
import * as rt from 'io-ts';
import { Color } from '../color_palette';
import {
metricsExplorerAggregationRT,
metricsExplorerMetricRT,
} from '../http_api/metrics_explorer';
export const metricsExplorerViewAttributesRT = rt.intersection([
rt.type({
name: nonEmptyStringRt,
isDefault: rt.boolean,
isStatic: rt.boolean,
export const inventorySortOptionRT = rt.type({
by: rt.keyof({ name: null, value: null }),
direction: rt.keyof({ asc: null, desc: null }),
});
export enum MetricsExplorerChartType {
line = 'line',
area = 'area',
bar = 'bar',
}
export enum MetricsExplorerYAxisMode {
fromZero = 'fromZero',
auto = 'auto',
}
export const metricsExplorerChartOptionsRT = rt.type({
yAxisMode: rt.keyof(
Object.fromEntries(Object.values(MetricsExplorerYAxisMode).map((v) => [v, null])) as Record<
MetricsExplorerYAxisMode,
null
>
),
type: rt.keyof(
Object.fromEntries(Object.values(MetricsExplorerChartType).map((v) => [v, null])) as Record<
MetricsExplorerChartType,
null
>
),
stack: rt.boolean,
});
export const metricsExplorerTimeOptionsRT = rt.type({
from: rt.string,
to: rt.string,
interval: rt.string,
});
const metricsExplorerOptionsMetricRT = rt.intersection([
metricsExplorerMetricRT,
rt.partial({
rate: rt.boolean,
color: rt.keyof(
Object.fromEntries(Object.values(Color).map((c) => [c, null])) as Record<Color, null>
),
label: rt.string,
}),
rt.UnknownRecord,
]);
export type MetricsExplorerViewAttributes = rt.TypeOf<typeof metricsExplorerViewAttributesRT>;
export const metricExplorerOptionsRequiredRT = rt.type({
aggregation: metricsExplorerAggregationRT,
metrics: rt.array(metricsExplorerOptionsMetricRT),
});
export const metricExplorerOptionsOptionalRT = rt.partial({
limit: rt.number,
groupBy: rt.union([rt.string, rt.array(rt.string)]),
filterQuery: rt.string,
source: rt.string,
forceInterval: rt.boolean,
dropLastBucket: rt.boolean,
});
export const metricsExplorerOptionsRT = rt.intersection([
metricExplorerOptionsRequiredRT,
metricExplorerOptionsOptionalRT,
]);
export const metricExplorerViewStateRT = rt.type({
chartOptions: metricsExplorerChartOptionsRT,
currentTimerange: metricsExplorerTimeOptionsRT,
options: metricsExplorerOptionsRT,
});
export const metricsExplorerViewBasicAttributesRT = rt.type({
name: nonEmptyStringRt,
});
const metricsExplorerViewFlagsRT = rt.partial({ isDefault: rt.boolean, isStatic: rt.boolean });
export const metricsExplorerViewAttributesRT = rt.intersection([
metricExplorerViewStateRT,
metricsExplorerViewBasicAttributesRT,
metricsExplorerViewFlagsRT,
]);
const singleMetricsExplorerViewAttributesRT = rt.exact(
rt.intersection([metricsExplorerViewBasicAttributesRT, metricsExplorerViewFlagsRT])
);
export const metricsExplorerViewRT = rt.exact(
rt.intersection([
@ -26,10 +108,29 @@ export const metricsExplorerViewRT = rt.exact(
attributes: metricsExplorerViewAttributesRT,
}),
rt.partial({
updatedAt: rt.number,
updatedAt: isoToEpochRt,
version: rt.string,
}),
])
);
export const singleMetricsExplorerViewRT = rt.exact(
rt.intersection([
rt.type({
id: rt.string,
attributes: singleMetricsExplorerViewAttributesRT,
}),
rt.partial({
updatedAt: isoToEpochRt,
version: rt.string,
}),
])
);
export type MetricsExplorerChartOptions = rt.TypeOf<typeof metricsExplorerChartOptionsRT>;
export type MetricsExplorerOptions = rt.TypeOf<typeof metricsExplorerOptionsRT>;
export type MetricsExplorerOptionsMetric = rt.TypeOf<typeof metricsExplorerOptionsMetricRT>;
export type MetricsExplorerViewState = rt.TypeOf<typeof metricExplorerViewStateRT>;
export type MetricsExplorerTimeOptions = rt.TypeOf<typeof metricsExplorerTimeOptionsRT>;
export type MetricsExplorerViewAttributes = rt.TypeOf<typeof metricsExplorerViewAttributesRT>;
export type MetricsExplorerView = rt.TypeOf<typeof metricsExplorerViewRT>;

View file

@ -13,7 +13,7 @@ import { MetricsSourceConfiguration } from '../../../../common/metrics_sources';
import { MetricExpression, TimeRange } from '../types';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
MetricsExplorerTimestamp,
} from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data';
import { MetricExplorerCustomMetricAggregations } from '../../../../common/http_api/metrics_explorer';
@ -59,7 +59,7 @@ export const useMetricsExplorerChartData = (
groupBy,
]
);
const timestamps: MetricsExplorerTimestampsRT = useMemo(() => {
const timestamps: MetricsExplorerTimestamp = useMemo(() => {
const from = timeRange.from ?? `now-${(timeSize || 1) * 20}${timeUnit}`;
const to = timeRange.to ?? 'now';
const fromTimestamp = DateMath.parse(from)!.valueOf();

View file

@ -10,11 +10,11 @@ import React, { useMemo } from 'react';
import { ThrowReporter } from 'io-ts/lib/ThrowReporter';
import { UrlStateContainer } from '../../utils/url_state';
import {
MetricsExplorerOptions,
type MetricsExplorerOptions,
type MetricsExplorerTimeOptions,
type MetricsExplorerChartOptions,
useMetricsExplorerOptionsContainerContext,
MetricsExplorerTimeOptions,
MetricsExplorerChartOptions,
metricExplorerOptionsRT,
metricsExplorerOptionsRT,
metricsExplorerChartOptionsRT,
metricsExplorerTimeOptionsRT,
} from '../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
@ -73,7 +73,7 @@ export const WithMetricsExplorerOptionsUrlState = () => {
};
function isMetricExplorerOptions(subject: any): subject is MetricsExplorerOptions {
const result = metricExplorerOptionsRT.decode(subject);
const result = metricsExplorerOptionsRT.decode(subject);
try {
ThrowReporter.report(result);

View file

@ -19,7 +19,10 @@ import {
UpdateViewParams,
} from '../../common/saved_views';
import { MetricsSourceConfigurationResponse } from '../../common/metrics_sources';
import { CreateInventoryViewAttributesRequestPayload } from '../../common/http_api/latest';
import {
CreateInventoryViewAttributesRequestPayload,
UpdateInventoryViewAttributesRequestPayload,
} from '../../common/http_api/latest';
import type { InventoryView } from '../../common/inventory_views';
import { useKibanaContextForPlugin } from './use_kibana';
import { useUrlState } from '../utils/use_url_state';
@ -133,7 +136,7 @@ export const useInventoryViews = (): UseInventoryViewsResult => {
const { mutateAsync: updateViewById, isLoading: isUpdatingView } = useMutation<
InventoryView,
ServerError,
UpdateViewParams<CreateInventoryViewAttributesRequestPayload>
UpdateViewParams<UpdateInventoryViewAttributesRequestPayload>
>({
mutationFn: ({ id, attributes }) => inventoryViews.client.updateInventoryView(id, attributes),
onError: (error) => {

View file

@ -19,7 +19,10 @@ import {
UpdateViewParams,
} from '../../common/saved_views';
import { MetricsSourceConfigurationResponse } from '../../common/metrics_sources';
import { CreateMetricsExplorerViewAttributesRequestPayload } from '../../common/http_api/latest';
import {
CreateMetricsExplorerViewAttributesRequestPayload,
UpdateMetricsExplorerViewAttributesRequestPayload,
} from '../../common/http_api/latest';
import { MetricsExplorerView } from '../../common/metrics_explorer_views';
import { useKibanaContextForPlugin } from './use_kibana';
import { useUrlState } from '../utils/use_url_state';
@ -133,7 +136,7 @@ export const useMetricsExplorerViews = (): UseMetricsExplorerViewsResult => {
const { mutateAsync: updateViewById, isLoading: isUpdatingView } = useMutation<
MetricsExplorerView,
ServerError,
UpdateViewParams<CreateMetricsExplorerViewAttributesRequestPayload>
UpdateViewParams<UpdateMetricsExplorerViewAttributesRequestPayload>
>({
mutationFn: ({ id, attributes }) =>
metricsExplorerViews.client.updateMetricsExplorerView(id, attributes),

View file

@ -8,10 +8,10 @@
import React from 'react';
import { useMetricsExplorerViews } from '../../../../hooks/use_metrics_explorer_views';
import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control';
import { MetricExplorerViewState } from '../hooks/use_metric_explorer_state';
import { MetricsExplorerViewState } from '../hooks/use_metric_explorer_state';
interface Props {
viewState: MetricExplorerViewState;
viewState: MetricsExplorerViewState;
}
export const SavedViews = ({ viewState }: Props) => {
@ -31,7 +31,7 @@ export const SavedViews = ({ viewState }: Props) => {
} = useMetricsExplorerViews();
return (
<SavedViewsToolbarControls<any, MetricExplorerViewState>
<SavedViewsToolbarControls
currentView={currentView}
views={views}
isFetchingViews={isFetchingViews}

View file

@ -8,26 +8,22 @@
import DateMath from '@kbn/datemath';
import { useCallback, useEffect } from 'react';
import { DataViewBase } from '@kbn/es-query';
import { MetricsExplorerView } from '../../../../../common/metrics_explorer_views';
import type {
MetricsExplorerChartOptions,
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
MetricsExplorerView,
MetricsExplorerViewState,
} from '../../../../../common/metrics_explorer_views';
import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources';
import {
MetricsExplorerMetric,
MetricsExplorerAggregation,
} from '../../../../../common/http_api/metrics_explorer';
import { useMetricsExplorerData } from './use_metrics_explorer_data';
import {
useMetricsExplorerOptionsContainerContext,
MetricsExplorerChartOptions,
MetricsExplorerTimeOptions,
MetricsExplorerOptions,
} from './use_metrics_explorer_options';
import { useMetricsExplorerOptionsContainerContext } from './use_metrics_explorer_options';
export interface MetricExplorerViewState {
chartOptions: MetricsExplorerChartOptions;
currentTimerange: MetricsExplorerTimeOptions;
options: MetricsExplorerOptions;
id?: string;
}
export type { MetricsExplorerViewState };
export const useMetricsExplorerState = (
source: MetricsSourceConfigurationProperties,

View file

@ -20,10 +20,7 @@ import {
resp,
createSeries,
} from '../../../../utils/fixtures/metrics_explorer';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
import { MetricsExplorerOptions, MetricsExplorerTimestamp } from './use_metrics_explorer_options';
import { DataViewBase } from '@kbn/es-query';
import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources';
@ -56,7 +53,7 @@ const renderUseMetricsExplorerDataHook = () => {
options: MetricsExplorerOptions;
source: MetricsSourceConfigurationProperties | undefined;
derivedIndexPattern: DataViewBase;
timestamps: MetricsExplorerTimestampsRT;
timestamps: MetricsExplorerTimestamp;
}) =>
useMetricsExplorerData(
props.options,

View file

@ -15,17 +15,14 @@ import {
metricsExplorerResponseRT,
} from '../../../../../common/http_api/metrics_explorer';
import { convertKueryToElasticSearchQuery } from '../../../../utils/kuery';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
import { MetricsExplorerOptions, MetricsExplorerTimestamp } from './use_metrics_explorer_options';
import { decodeOrThrow } from '../../../../../common/runtime_types';
export function useMetricsExplorerData(
options: MetricsExplorerOptions,
source: MetricsSourceConfigurationProperties | undefined,
derivedIndexPattern: DataViewBase,
{ fromTimestamp, toTimestamp, interval }: MetricsExplorerTimestampsRT,
{ fromTimestamp, toTimestamp, interval }: MetricsExplorerTimestamp,
enabled = true
) {
const { http } = useKibana().services;

View file

@ -7,91 +7,48 @@
import DateMath from '@kbn/datemath';
import * as t from 'io-ts';
import { values } from 'lodash';
import createContainer from 'constate';
import type { TimeRange } from '@kbn/es-query';
import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react';
import {
type MetricsExplorerChartOptions,
type MetricsExplorerOptions,
type MetricsExplorerOptionsMetric,
type MetricsExplorerTimeOptions,
MetricsExplorerYAxisMode,
MetricsExplorerChartType,
metricsExplorerOptionsRT,
metricsExplorerChartOptionsRT,
metricsExplorerTimeOptionsRT,
} from '../../../../../common/metrics_explorer_views';
import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill';
import { Color } from '../../../../../common/color_palette';
import { metricsExplorerMetricRT } from '../../../../../common/http_api/metrics_explorer';
import {
useKibanaTimefilterTime,
useSyncKibanaTimeFilterTime,
} from '../../../../hooks/use_kibana_timefilter_time';
const metricsExplorerOptionsMetricRT = t.intersection([
metricsExplorerMetricRT,
t.partial({
rate: t.boolean,
color: t.keyof(Object.fromEntries(values(Color).map((c) => [c, null])) as Record<Color, null>),
label: t.string,
}),
]);
export type MetricsExplorerOptionsMetric = t.TypeOf<typeof metricsExplorerOptionsMetricRT>;
export enum MetricsExplorerChartType {
line = 'line',
area = 'area',
bar = 'bar',
}
export enum MetricsExplorerYAxisMode {
fromZero = 'fromZero',
auto = 'auto',
}
export const metricsExplorerChartOptionsRT = t.type({
yAxisMode: t.keyof(
Object.fromEntries(values(MetricsExplorerYAxisMode).map((v) => [v, null])) as Record<
MetricsExplorerYAxisMode,
null
>
),
type: t.keyof(
Object.fromEntries(values(MetricsExplorerChartType).map((v) => [v, null])) as Record<
MetricsExplorerChartType,
null
>
),
stack: t.boolean,
});
export type MetricsExplorerChartOptions = t.TypeOf<typeof metricsExplorerChartOptionsRT>;
const metricExplorerOptionsRequiredRT = t.type({
aggregation: t.string,
metrics: t.array(metricsExplorerOptionsMetricRT),
});
const metricExplorerOptionsOptionalRT = t.partial({
limit: t.number,
groupBy: t.union([t.string, t.array(t.string)]),
filterQuery: t.string,
source: t.string,
forceInterval: t.boolean,
dropLastBucket: t.boolean,
});
export const metricExplorerOptionsRT = t.intersection([
metricExplorerOptionsRequiredRT,
metricExplorerOptionsOptionalRT,
]);
export type MetricsExplorerOptions = t.TypeOf<typeof metricExplorerOptionsRT>;
export const metricsExplorerTimestampsRT = t.type({
fromTimestamp: t.number,
toTimestamp: t.number,
interval: t.string,
});
export type MetricsExplorerTimestampsRT = t.TypeOf<typeof metricsExplorerTimestampsRT>;
export const metricsExplorerTimeOptionsRT = t.type({
from: t.string,
to: t.string,
interval: t.string,
});
export type MetricsExplorerTimeOptions = t.TypeOf<typeof metricsExplorerTimeOptionsRT>;
export type {
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
MetricsExplorerChartOptions,
MetricsExplorerOptionsMetric,
};
export {
MetricsExplorerYAxisMode,
MetricsExplorerChartType,
metricsExplorerOptionsRT,
metricsExplorerChartOptionsRT,
metricsExplorerTimeOptionsRT,
};
export type MetricsExplorerTimestamp = t.TypeOf<typeof metricsExplorerTimestampsRT>;
export const DEFAULT_TIMERANGE: MetricsExplorerTimeOptions = {
from: 'now-1h',
@ -182,7 +139,7 @@ export const useMetricsExplorerOptions = () => {
to,
interval: DEFAULT_TIMERANGE.interval,
});
const [timestamps, setTimestamps] = useState<MetricsExplorerTimestampsRT>(
const [timestamps, setTimestamps] = useState<MetricsExplorerTimestamp>(
getDefaultTimeRange({ from, to })
);

View file

@ -7,17 +7,20 @@
import { HttpStart } from '@kbn/core/public';
import {
CreateMetricsExplorerViewAttributesRequestPayload,
CreateMetricsExplorerViewResponsePayload,
createMetricsExplorerViewRequestPayloadRT,
FindMetricsExplorerViewResponsePayload,
findMetricsExplorerViewResponsePayloadRT,
GetMetricsExplorerViewResponsePayload,
getMetricsExplorerViewUrl,
metricsExplorerViewResponsePayloadRT,
UpdateMetricsExplorerViewResponsePayload,
CreateMetricsExplorerViewAttributesRequestPayload,
UpdateMetricsExplorerViewAttributesRequestPayload,
} from '../../../common/http_api/latest';
import {
DeleteMetricsExplorerViewError,
FetchMetricsExplorerViewError,
MetricsExplorerView,
UpsertMetricsExplorerViewError,
} from '../../../common/metrics_explorer_views';
import { decodeOrThrow } from '../../../common/runtime_types';
@ -26,7 +29,7 @@ import { IMetricsExplorerViewsClient } from './types';
export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
constructor(private readonly http: HttpStart) {}
async findMetricsExplorerViews(): Promise<MetricsExplorerView[]> {
async findMetricsExplorerViews(): Promise<FindMetricsExplorerViewResponsePayload['data']> {
const response = await this.http.get(getMetricsExplorerViewUrl()).catch((error) => {
throw new FetchMetricsExplorerViewError(`Failed to fetch metrics explorer views: ${error}`);
});
@ -40,7 +43,9 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
return data;
}
async getMetricsExplorerView(metricsExplorerViewId: string): Promise<MetricsExplorerView> {
async getMetricsExplorerView(
metricsExplorerViewId: string
): Promise<GetMetricsExplorerViewResponsePayload> {
const response = await this.http
.get(getMetricsExplorerViewUrl(metricsExplorerViewId))
.catch((error) => {
@ -62,7 +67,7 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
async createMetricsExplorerView(
metricsExplorerViewAttributes: CreateMetricsExplorerViewAttributesRequestPayload
): Promise<MetricsExplorerView> {
): Promise<CreateMetricsExplorerViewResponsePayload> {
const response = await this.http
.post(getMetricsExplorerViewUrl(), {
body: JSON.stringify(
@ -91,7 +96,7 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
async updateMetricsExplorerView(
metricsExplorerViewId: string,
metricsExplorerViewAttributes: UpdateMetricsExplorerViewAttributesRequestPayload
): Promise<MetricsExplorerView> {
): Promise<UpdateMetricsExplorerViewResponsePayload> {
const response = await this.http
.put(getMetricsExplorerViewUrl(metricsExplorerViewId), {
body: JSON.stringify(

View file

@ -7,9 +7,12 @@
import { HttpStart } from '@kbn/core/public';
import {
MetricsExplorerView,
MetricsExplorerViewAttributes,
} from '../../../common/metrics_explorer_views';
FindMetricsExplorerViewResponsePayload,
CreateMetricsExplorerViewResponsePayload,
UpdateMetricsExplorerViewResponsePayload,
GetMetricsExplorerViewResponsePayload,
} from '../../../common/http_api';
import { MetricsExplorerViewAttributes } from '../../../common/metrics_explorer_views';
export type MetricsExplorerViewsServiceSetup = void;
@ -22,14 +25,16 @@ export interface MetricsExplorerViewsServiceStartDeps {
}
export interface IMetricsExplorerViewsClient {
findMetricsExplorerViews(): Promise<MetricsExplorerView[]>;
getMetricsExplorerView(metricsExplorerViewId: string): Promise<MetricsExplorerView>;
findMetricsExplorerViews(): Promise<FindMetricsExplorerViewResponsePayload['data']>;
getMetricsExplorerView(
metricsExplorerViewId: string
): Promise<GetMetricsExplorerViewResponsePayload>;
createMetricsExplorerView(
metricsExplorerViewAttributes: Partial<MetricsExplorerViewAttributes>
): Promise<MetricsExplorerView>;
): Promise<CreateMetricsExplorerViewResponsePayload>;
updateMetricsExplorerView(
metricsExplorerViewId: string,
metricsExplorerViewAttributes: Partial<MetricsExplorerViewAttributes>
): Promise<MetricsExplorerView>;
): Promise<UpdateMetricsExplorerViewResponsePayload>;
deleteMetricsExplorerView(metricsExplorerViewId: string): Promise<null>;
}

View file

@ -15,7 +15,7 @@ import {
MetricsExplorerChartType,
MetricsExplorerYAxisMode,
MetricsExplorerChartOptions,
MetricsExplorerTimestampsRT,
MetricsExplorerTimestamp,
} from '../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
export const options: MetricsExplorerOptions = {
@ -56,7 +56,7 @@ export const timeRange: MetricsExplorerTimeOptions = {
interval: '>=10s',
};
export const timestamps: MetricsExplorerTimestampsRT = {
export const timestamps: MetricsExplorerTimestamp = {
fromTimestamp: 1678376367166,
toTimestamp: 1678379973620,
interval: '>=10s',

View file

@ -77,38 +77,32 @@ Status code: 200
"updatedAt": 1681398305034,
"attributes": {
"name": "Ad-hoc",
"isDefault": true,
"isStatic": false,
"metric": {
"type": "cpu"
"options": {
"aggregation": "avg",
"metrics": [
{
"aggregation": "avg",
"field": "system.cpu.total.norm.pct",
"color": "color0"
},
],
"source": "default",
"groupBy": [
"host.name"
]
},
"sort": {
"by": "name",
"direction": "desc"
"chartOptions": {
"type": "line",
"yAxisMode": "fromZero",
"stack": false
},
"groupBy": [],
"nodeType": "host",
"view": "map",
"customOptions": [],
"customMetrics": [],
"boundsOverride": {
"max": 1,
"min": 0
"currentTimerange": {
"from": "now-1h",
"to": "now",
"interval": ">=10s"
},
"autoBounds": true,
"accountId": "",
"region": "",
"autoReload": false,
"filterQuery": {
"expression": "",
"kind": "kuery"
},
"legend": {
"palette": "cool",
"reverseColors": false,
"steps": 10
},
"timelineOpen": false
"isDefault": false,
"isStatic": false
}
}
}
@ -130,23 +124,47 @@ Status code: 404
Creates a new metrics explorer view.
`aggregation`: `"avg" | "max" | "min" | "cardinality" | "rate" | "count" | "sum" | "p95" | "p99" | "custom"`
`metrics.aggregation`: `"avg" | "max" | "min" | "cardinality" | "rate" | "count" | "sum" | "p95" | "p99" | "custom"`
`chartOptions.type`: `"line" | "area" | "bar"`
`chartOptions.yAxisMode`: `"fromZero" | "auto" | "bar"`
### Request
- **Method**: POST
- **Path**: /api/infra/metrics_explorer_views
- **Request body**:
```json
{
"attributes": {
"name": "View name",
"metric": {
"type": "cpu"
"options": {
"aggregation": "avg",
"metrics": [
{
"aggregation": "avg",
"field": "system.cpu.total.norm.pct",
"color": "color0"
},
],
"source": "default",
"groupBy": [
"host.name"
]
},
"sort": {
"by": "name",
"direction": "desc"
"chartOptions": {
"type": "line",
"yAxisMode": "fromZero",
"stack": false
},
"currentTimerange": {
"from": "now-1h",
"to": "now",
"interval": ">=10s"
},
//...
}
}
```
@ -165,38 +183,32 @@ Status code: 201
"updatedAt": 1681398305034,
"attributes": {
"name": "View name",
"options": {
"aggregation": "avg",
"metrics": [
{
"aggregation": "avg",
"field": "system.cpu.total.norm.pct",
"color": "color0"
},
],
"source": "default",
"groupBy": [
"host.name"
]
},
"chartOptions": {
"type": "line",
"yAxisMode": "fromZero",
"stack": false
},
"currentTimerange": {
"from": "now-1h",
"to": "now",
"interval": ">=10s"
},
"isDefault": false,
"isStatic": false,
"metric": {
"type": "cpu"
},
"sort": {
"by": "name",
"direction": "desc"
},
"groupBy": [],
"nodeType": "host",
"view": "map",
"customOptions": [],
"customMetrics": [],
"boundsOverride": {
"max": 1,
"min": 0
},
"autoBounds": true,
"accountId": "",
"region": "",
"autoReload": false,
"filterQuery": {
"expression": "",
"kind": "kuery"
},
"legend": {
"palette": "cool",
"reverseColors": false,
"steps": 10
},
"timelineOpen": false
"isStatic": false
}
}
}
@ -234,14 +246,30 @@ Any attempt to update the static view with id `0` will return a `400 The metrics
{
"attributes": {
"name": "View name",
"metric": {
"type": "cpu"
"options": {
"aggregation": "avg",
"metrics": [
{
"aggregation": "avg",
"field": "system.cpu.total.norm.pct",
"color": "color0"
},
],
"source": "default",
"groupBy": [
"host.name"
]
},
"sort": {
"by": "name",
"direction": "desc"
"chartOptions": {
"type": "line",
"yAxisMode": "fromZero",
"stack": false
},
//...
"currentTimerange": {
"from": "now-1h",
"to": "now",
"interval": ">=10s"
}
}
}
```
@ -260,38 +288,32 @@ Status code: 200
"updatedAt": 1681398305034,
"attributes": {
"name": "View name",
"options": {
"aggregation": "avg",
"metrics": [
{
"aggregation": "avg",
"field": "system.cpu.total.norm.pct",
"color": "color0"
},
],
"source": "default",
"groupBy": [
"host.name"
]
},
"chartOptions": {
"type": "line",
"yAxisMode": "fromZero",
"stack": false
},
"currentTimerange": {
"from": "now-1h",
"to": "now",
"interval": ">=10s"
},
"isDefault": false,
"isStatic": false,
"metric": {
"type": "cpu"
},
"sort": {
"by": "name",
"direction": "desc"
},
"groupBy": [],
"nodeType": "host",
"view": "map",
"customOptions": [],
"customMetrics": [],
"boundsOverride": {
"max": 1,
"min": 0
},
"autoBounds": true,
"accountId": "",
"region": "",
"autoReload": false,
"filterQuery": {
"expression": "",
"kind": "kuery"
},
"legend": {
"palette": "cool",
"reverseColors": false,
"steps": 10
},
"timelineOpen": false
"isStatic": false
}
}
}

View file

@ -8,11 +8,59 @@
import { isoToEpochRt, nonEmptyStringRt } from '@kbn/io-ts-utils';
import * as rt from 'io-ts';
export const metricsExplorerViewSavedObjectAttributesRT = rt.intersection([
rt.strict({
const metricsExplorerSavedObjectChartTypeRT = rt.keyof({ line: null, area: null, bar: null });
const metricsExplorerYAxisModeRT = rt.keyof({ fromZero: null, auto: null });
const metricsExplorerSavedObjectChartOptionsRT = rt.type({
yAxisMode: metricsExplorerYAxisModeRT,
type: metricsExplorerSavedObjectChartTypeRT,
stack: rt.boolean,
});
export const metricsExplorerSavedObjectTimeOptionsRT = rt.type({
from: rt.string,
to: rt.string,
interval: rt.string,
});
const metricsExplorerSavedObjectOptionsMetricRT = rt.intersection([
rt.UnknownRecord,
rt.partial({
rate: rt.boolean,
color: rt.string,
label: rt.string,
}),
]);
const metricExplorerSavedObjectOptionsRequiredRT = rt.type({
aggregation: rt.string,
metrics: rt.array(metricsExplorerSavedObjectOptionsMetricRT),
});
const metricExplorerSavedObjectOptionsOptionalRT = rt.partial({
limit: rt.number,
groupBy: rt.union([rt.string, rt.array(rt.string)]),
filterQuery: rt.string,
source: rt.string,
forceInterval: rt.boolean,
dropLastBucket: rt.boolean,
});
export const metricsExplorerSavedObjectOptionsRT = rt.intersection([
metricExplorerSavedObjectOptionsRequiredRT,
metricExplorerSavedObjectOptionsOptionalRT,
]);
const metricExplorerViewsSavedObjectStateRT = rt.type({
chartOptions: metricsExplorerSavedObjectChartOptionsRT,
currentTimerange: metricsExplorerSavedObjectTimeOptionsRT,
options: metricsExplorerSavedObjectOptionsRT,
});
const metricsExplorerViewSavedObjectAttributesRT = rt.intersection([
metricExplorerViewsSavedObjectStateRT,
rt.type({
name: nonEmptyStringRt,
}),
rt.UnknownRecord,
rt.partial({ isDefault: rt.boolean, isStatic: rt.boolean }),
]);
export const metricsExplorerViewSavedObjectRT = rt.intersection([

View file

@ -14,12 +14,16 @@ import {
} from '@kbn/core/server';
import Boom from '@hapi/boom';
import {
metricsExplorerViewAttributesRT,
staticMetricsExplorerViewAttributes,
staticMetricsExplorerViewId,
} from '../../../common/metrics_explorer_views';
import type {
CreateMetricsExplorerViewAttributesRequestPayload,
FindMetricsExplorerViewResponsePayload,
GetMetricsExplorerViewResponsePayload,
MetricsExplorerViewRequestQuery,
UpdateMetricsExplorerViewResponsePayload,
} from '../../../common/http_api/latest';
import type {
MetricsExplorerView,
@ -41,7 +45,9 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
static STATIC_VIEW_ID = '0';
static DEFAULT_SOURCE_ID = 'default';
public async find(query: MetricsExplorerViewRequestQuery): Promise<MetricsExplorerView[]> {
public async find(
query: MetricsExplorerViewRequestQuery
): Promise<FindMetricsExplorerViewResponsePayload['data']> {
this.logger.debug('Trying to load metrics explorer views ...');
const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID;
@ -71,7 +77,7 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
public async get(
metricsExplorerViewId: string,
query: MetricsExplorerViewRequestQuery
): Promise<MetricsExplorerView> {
): Promise<GetMetricsExplorerViewResponsePayload> {
this.logger.debug(`Trying to load metrics explorer view with id ${metricsExplorerViewId} ...`);
const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID;
@ -103,7 +109,7 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
metricsExplorerViewId: string | null,
attributes: CreateMetricsExplorerViewAttributesRequestPayload,
query: MetricsExplorerViewRequestQuery
): Promise<MetricsExplorerView> {
): Promise<UpdateMetricsExplorerViewResponsePayload> {
this.logger.debug(
`Trying to update metrics explorer view with id "${metricsExplorerViewId}"...`
);
@ -147,10 +153,10 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
});
}
private mapSavedObjectToMetricsExplorerView(
savedObject: SavedObject | SavedObjectsUpdateResponse,
private mapSavedObjectToMetricsExplorerView<T>(
savedObject: SavedObject<T> | SavedObjectsUpdateResponse<T>,
defaultViewId?: string
) {
): MetricsExplorerView {
const metricsExplorerViewSavedObject = decodeOrThrow(metricsExplorerViewSavedObjectRT)(
savedObject
);
@ -160,7 +166,9 @@ export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient {
version: metricsExplorerViewSavedObject.version,
updatedAt: metricsExplorerViewSavedObject.updated_at,
attributes: {
...metricsExplorerViewSavedObject.attributes,
...decodeOrThrow(metricsExplorerViewAttributesRT)(
metricsExplorerViewSavedObject.attributes
),
isDefault: metricsExplorerViewSavedObject.id === defaultViewId,
isStatic: false,
},

View file

@ -11,10 +11,12 @@ import type {
SavedObjectsServiceStart,
} from '@kbn/core/server';
import type {
FindMetricsExplorerViewResponsePayload,
GetMetricsExplorerViewResponsePayload,
MetricsExplorerViewRequestQuery,
UpdateMetricsExplorerViewAttributesRequestPayload,
UpdateMetricsExplorerViewResponsePayload,
} from '../../../common/http_api/latest';
import type { MetricsExplorerView } from '../../../common/metrics_explorer_views';
import type { InfraSources } from '../../lib/sources';
export interface MetricsExplorerViewsServiceStartDeps {
@ -31,14 +33,16 @@ export interface MetricsExplorerViewsServiceStart {
export interface IMetricsExplorerViewsClient {
delete(metricsExplorerViewId: string): Promise<{}>;
find(query: MetricsExplorerViewRequestQuery): Promise<MetricsExplorerView[]>;
find(
query: MetricsExplorerViewRequestQuery
): Promise<FindMetricsExplorerViewResponsePayload['data']>;
get(
metricsExplorerViewId: string,
query: MetricsExplorerViewRequestQuery
): Promise<MetricsExplorerView>;
): Promise<GetMetricsExplorerViewResponsePayload>;
update(
metricsExplorerViewId: string | null,
metricsExplorerViewAttributes: UpdateMetricsExplorerViewAttributesRequestPayload,
query: MetricsExplorerViewRequestQuery
): Promise<MetricsExplorerView>;
): Promise<UpdateMetricsExplorerViewResponsePayload>;
}