mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Infrastructure UI] Add lens charts to hosts view (#148906)
## Summary closes https://github.com/elastic/obs-infraobs-team/issues/921 This PR introduces a series of lens charts to hosts view page.  The Lens embeddable component is fully integrated, allowing the filters to react to clicks on the charts as well as accessing Lens directly from the chart The charts will show metrics for: - Load - CPU Usage - RX - TX - Memory - Disk Read - Disk Write ### How to test - Using metricbeat - Enable system metric in metricbeat - Start your local ES - Using oblt - configure kibana.dev.yml with your oblt-cluster - Using slingshot - Clone https://github.com/elastic/slingshot and run slingshot yarn slingshot load --config ./configs/hosts.json - Start your local ES - Start kibana - Navigate to Infrastructure > Hosts ### Notes - I've renamed a few things that started to become confusing after this change. - The structure created for the lens vis is similar to the inventory_model, but here is only used in `public` and I've placed them as a common functionality, since this might also be used in other pages in the future. Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
ce075504ae
commit
252d81c46a
39 changed files with 1974 additions and 135 deletions
|
@ -12,15 +12,24 @@
|
|||
"dataViews",
|
||||
"visTypeTimeseries",
|
||||
"alerting",
|
||||
"lens",
|
||||
"triggersActionsUi",
|
||||
"observability",
|
||||
"ruleRegistry",
|
||||
"unifiedSearch"
|
||||
],
|
||||
"optionalPlugins": ["ml", "home", "embeddable", "osquery"],
|
||||
"optionalPlugins": [
|
||||
"ml",
|
||||
"home",
|
||||
"embeddable",
|
||||
"osquery"
|
||||
],
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"configPath": ["xpack", "infra"],
|
||||
"configPath": [
|
||||
"xpack",
|
||||
"infra"
|
||||
],
|
||||
"requiredBundles": [
|
||||
"unifiedSearch",
|
||||
"observability",
|
||||
|
@ -29,11 +38,11 @@
|
|||
"kibanaReact",
|
||||
"ml",
|
||||
"embeddable",
|
||||
"controls"
|
||||
"controls"
|
||||
],
|
||||
"owner": {
|
||||
"name": "Logs and Metrics UI",
|
||||
"githubTeam": "logs-metrics-ui"
|
||||
},
|
||||
"description": "This plugin visualizes data from Filebeat and Metricbeat, and integrates with other Observability solutions"
|
||||
}
|
||||
}
|
22
x-pack/plugins/infra/public/common/visualizations/index.ts
Normal file
22
x-pack/plugins/infra/public/common/visualizations/index.ts
Normal 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 { CPU, Load, Memory, RX, TX, DiskIORead, DiskIOWrite } from './lens/hosts';
|
||||
|
||||
export { buildLensAttributes } from './lens/lens_visualization';
|
||||
|
||||
export const hostMetricsLensAttributes = {
|
||||
cpu: CPU,
|
||||
load: Load,
|
||||
memory: Memory,
|
||||
rx: RX,
|
||||
tx: TX,
|
||||
diskIORead: DiskIORead,
|
||||
diskIOWrite: DiskIOWrite,
|
||||
};
|
||||
|
||||
export type HostLensAttributesTypes = keyof typeof hostMetricsLensAttributes;
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* 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 Filter, FilterStateStore } from '@kbn/es-query';
|
||||
import type {
|
||||
FormBasedLayer,
|
||||
FormulaPublicApi,
|
||||
PersistedIndexPatternLayer,
|
||||
XYState,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import {
|
||||
DEFAULT_LAYER_ID,
|
||||
getBreakdownColumn,
|
||||
getHistogramColumn,
|
||||
getXYVisualizationState,
|
||||
} from '../utils';
|
||||
import type { LensOptions } from '../../../../types';
|
||||
import type { ILensVisualization } from '../types';
|
||||
|
||||
const BREAKDOWN_COLUMN_NAME = 'hosts_aggs_breakdown';
|
||||
const HISTOGRAM_COLUMN_NAME = 'x_date_histogram';
|
||||
|
||||
export class CPU implements ILensVisualization {
|
||||
constructor(
|
||||
private dataView: DataView,
|
||||
private options: LensOptions,
|
||||
private formula: FormulaPublicApi
|
||||
) {}
|
||||
|
||||
getTitle(): string {
|
||||
return 'CPU Usage';
|
||||
}
|
||||
|
||||
getVisualizationType(): string {
|
||||
return 'lnsXY';
|
||||
}
|
||||
|
||||
getLayers = (): Record<string, Omit<FormBasedLayer, 'indexPatternId'>> => {
|
||||
const baseLayer: PersistedIndexPatternLayer = {
|
||||
columnOrder: [BREAKDOWN_COLUMN_NAME, HISTOGRAM_COLUMN_NAME],
|
||||
columns: {
|
||||
...getBreakdownColumn(BREAKDOWN_COLUMN_NAME, 'host.name', this.options.breakdownSize),
|
||||
...getHistogramColumn(HISTOGRAM_COLUMN_NAME, this.dataView.timeFieldName ?? '@timestamp'),
|
||||
},
|
||||
};
|
||||
|
||||
const dataLayer = this.formula.insertOrReplaceFormulaColumn(
|
||||
'y_cpu_usage',
|
||||
{
|
||||
formula: 'average(system.cpu.total.norm.pct)',
|
||||
format: {
|
||||
id: 'percent',
|
||||
params: {
|
||||
decimals: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
baseLayer,
|
||||
this.dataView
|
||||
);
|
||||
|
||||
if (!dataLayer) {
|
||||
throw new Error('Error generating the data layer for the chart');
|
||||
}
|
||||
|
||||
return { [DEFAULT_LAYER_ID]: dataLayer };
|
||||
};
|
||||
getVisualizationState = (): XYState => {
|
||||
return getXYVisualizationState({
|
||||
layers: [
|
||||
{
|
||||
layerId: DEFAULT_LAYER_ID,
|
||||
seriesType: 'line',
|
||||
accessors: ['y_cpu_usage'],
|
||||
yConfig: [],
|
||||
layerType: 'data',
|
||||
xAccessor: HISTOGRAM_COLUMN_NAME,
|
||||
splitAccessor: BREAKDOWN_COLUMN_NAME,
|
||||
},
|
||||
],
|
||||
yLeftExtent: {
|
||||
mode: 'custom',
|
||||
lowerBound: 0,
|
||||
upperBound: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
getFilters = (): Filter[] => {
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: '3be1e71b-4bc5-4462-a314-04539f877a19',
|
||||
key: 'system.cpu.total.norm.pct',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.cpu.total.norm.pct',
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
getReferences = (): SavedObjectReference[] => {
|
||||
return [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${DEFAULT_LAYER_ID}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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 Filter, FilterStateStore } from '@kbn/es-query';
|
||||
import type {
|
||||
FormBasedLayer,
|
||||
FormulaPublicApi,
|
||||
PersistedIndexPatternLayer,
|
||||
XYState,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import type { LensOptions } from '../../../../types';
|
||||
import {
|
||||
DEFAULT_LAYER_ID,
|
||||
getBreakdownColumn,
|
||||
getHistogramColumn,
|
||||
getXYVisualizationState,
|
||||
} from '../utils';
|
||||
import { ILensVisualization } from '../types';
|
||||
|
||||
const BREAKDOWN_COLUMN_NAME = 'hosts_aggs_breakdown';
|
||||
const HISTOGRAM_COLUMN_NAME = 'x_date_histogram';
|
||||
|
||||
export class DiskIORead implements ILensVisualization {
|
||||
constructor(
|
||||
private dataView: DataView,
|
||||
private options: LensOptions,
|
||||
private formula: FormulaPublicApi
|
||||
) {}
|
||||
|
||||
getTitle(): string {
|
||||
return 'Disk Read IOPS';
|
||||
}
|
||||
|
||||
getVisualizationType(): string {
|
||||
return 'lnsXY';
|
||||
}
|
||||
|
||||
getLayers = (): Record<string, Omit<FormBasedLayer, 'indexPatternId'>> => {
|
||||
const baseLayer: PersistedIndexPatternLayer = {
|
||||
columnOrder: [BREAKDOWN_COLUMN_NAME, HISTOGRAM_COLUMN_NAME],
|
||||
columns: {
|
||||
...getBreakdownColumn(BREAKDOWN_COLUMN_NAME, 'host.name', this.options.breakdownSize),
|
||||
...getHistogramColumn(HISTOGRAM_COLUMN_NAME, this.dataView.timeFieldName ?? '@timestamp'),
|
||||
},
|
||||
};
|
||||
|
||||
const dataLayer = this.formula.insertOrReplaceFormulaColumn(
|
||||
'y_diskio_read',
|
||||
{
|
||||
formula: "counter_rate(max(system.diskio.read.bytes), kql='system.diskio.read.bytes >= 0')",
|
||||
format: {
|
||||
id: 'bytes',
|
||||
params: {
|
||||
decimals: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
baseLayer,
|
||||
this.dataView
|
||||
);
|
||||
|
||||
if (!dataLayer) {
|
||||
throw new Error('Error generating the data layer for the chart');
|
||||
}
|
||||
|
||||
return { [DEFAULT_LAYER_ID]: dataLayer };
|
||||
};
|
||||
|
||||
getVisualizationState = (): XYState => {
|
||||
return getXYVisualizationState({
|
||||
layers: [
|
||||
{
|
||||
layerId: DEFAULT_LAYER_ID,
|
||||
seriesType: 'line',
|
||||
accessors: ['y_diskio_read'],
|
||||
yConfig: [],
|
||||
layerType: 'data',
|
||||
xAccessor: HISTOGRAM_COLUMN_NAME,
|
||||
splitAccessor: BREAKDOWN_COLUMN_NAME,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
getFilters = (): Filter[] => {
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: '3be1e71b-4bc5-4462-a314-04539f877a19',
|
||||
key: 'system.diskio.read.bytes',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.diskio.read.bytes',
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
getReferences = (): SavedObjectReference[] => {
|
||||
return [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${DEFAULT_LAYER_ID}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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 Filter, FilterStateStore } from '@kbn/es-query';
|
||||
import type {
|
||||
FormBasedLayer,
|
||||
FormulaPublicApi,
|
||||
PersistedIndexPatternLayer,
|
||||
XYState,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import type { LensOptions } from '../../../../types';
|
||||
import {
|
||||
DEFAULT_LAYER_ID,
|
||||
getBreakdownColumn,
|
||||
getHistogramColumn,
|
||||
getXYVisualizationState,
|
||||
} from '../utils';
|
||||
import { ILensVisualization } from '../types';
|
||||
|
||||
const BREAKDOWN_COLUMN_NAME = 'hosts_aggs_breakdown';
|
||||
const HISTOGRAM_COLUMN_NAME = 'x_date_histogram';
|
||||
|
||||
export class DiskIOWrite implements ILensVisualization {
|
||||
constructor(
|
||||
private dataView: DataView,
|
||||
private options: LensOptions,
|
||||
private formula: FormulaPublicApi
|
||||
) {}
|
||||
|
||||
getTitle(): string {
|
||||
return 'Disk Write IOPS';
|
||||
}
|
||||
|
||||
getVisualizationType(): string {
|
||||
return 'lnsXY';
|
||||
}
|
||||
|
||||
getLayers = (): Record<string, Omit<FormBasedLayer, 'indexPatternId'>> => {
|
||||
const baseLayer: PersistedIndexPatternLayer = {
|
||||
columnOrder: [BREAKDOWN_COLUMN_NAME, HISTOGRAM_COLUMN_NAME],
|
||||
columns: {
|
||||
...getBreakdownColumn(BREAKDOWN_COLUMN_NAME, 'host.name', this.options.breakdownSize),
|
||||
...getHistogramColumn(HISTOGRAM_COLUMN_NAME, this.dataView.timeFieldName ?? '@timestamp'),
|
||||
},
|
||||
};
|
||||
|
||||
const dataLayer = this.formula.insertOrReplaceFormulaColumn(
|
||||
'y_diskio_write',
|
||||
{
|
||||
formula:
|
||||
"counter_rate(max(system.diskio.write.bytes), kql='system.diskio.write.bytes>= 0')",
|
||||
format: {
|
||||
id: 'bytes',
|
||||
params: {
|
||||
decimals: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
baseLayer,
|
||||
this.dataView
|
||||
);
|
||||
|
||||
if (!dataLayer) {
|
||||
throw new Error('Error generating the data layer for the chart');
|
||||
}
|
||||
|
||||
return { [DEFAULT_LAYER_ID]: dataLayer };
|
||||
};
|
||||
|
||||
getVisualizationState = (): XYState => {
|
||||
return getXYVisualizationState({
|
||||
layers: [
|
||||
{
|
||||
layerId: DEFAULT_LAYER_ID,
|
||||
seriesType: 'line',
|
||||
accessors: ['y_diskio_write'],
|
||||
yConfig: [],
|
||||
layerType: 'data',
|
||||
xAccessor: HISTOGRAM_COLUMN_NAME,
|
||||
splitAccessor: BREAKDOWN_COLUMN_NAME,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
getFilters = (): Filter[] => {
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: '3be1e71b-4bc5-4462-a314-04539f877a19',
|
||||
key: 'system.diskio.write.bytes',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.diskio.write.bytes',
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
getReferences = (): SavedObjectReference[] => {
|
||||
return [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${DEFAULT_LAYER_ID}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
|
@ -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 { CPU } from './cpu';
|
||||
export { Load } from './load';
|
||||
export { Memory } from './memory';
|
||||
export { RX } from './rx';
|
||||
export { TX } from './tx';
|
||||
export { DiskIORead } from './diskio_read';
|
||||
export { DiskIOWrite } from './diskio_write';
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
* 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 Filter, FilterStateStore } from '@kbn/es-query';
|
||||
import type {
|
||||
PersistedIndexPatternLayer,
|
||||
FormulaPublicApi,
|
||||
XYState,
|
||||
FormBasedLayer,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import { ReferenceBasedIndexPatternColumn } from '@kbn/lens-plugin/public/datasources/form_based/operations/definitions/column_types';
|
||||
import {
|
||||
DEFAULT_LAYER_ID,
|
||||
getBreakdownColumn,
|
||||
getHistogramColumn,
|
||||
getXYVisualizationState,
|
||||
} from '../utils';
|
||||
import type { LensOptions } from '../../../../types';
|
||||
import type { ILensVisualization } from '../types';
|
||||
|
||||
const BREAKDOWN_COLUMN_NAME = 'hosts_aggs_breakdown';
|
||||
const HISTOGRAM_COLUMN_NAME = 'x_date_histogram';
|
||||
const REFERENCE_LAYER = 'referenceLayer';
|
||||
|
||||
export class Load implements ILensVisualization {
|
||||
constructor(
|
||||
private dataView: DataView,
|
||||
private options: LensOptions,
|
||||
private formula: FormulaPublicApi
|
||||
) {}
|
||||
|
||||
getTitle(): string {
|
||||
return 'Normalized Load';
|
||||
}
|
||||
|
||||
getVisualizationType(): string {
|
||||
return 'lnsXY';
|
||||
}
|
||||
|
||||
getLayers = (): Record<string, Omit<FormBasedLayer, 'indexPatternId'>> => {
|
||||
const baseLayer: PersistedIndexPatternLayer = {
|
||||
columnOrder: [BREAKDOWN_COLUMN_NAME, HISTOGRAM_COLUMN_NAME],
|
||||
columns: {
|
||||
...getBreakdownColumn(BREAKDOWN_COLUMN_NAME, 'host.name', this.options.breakdownSize),
|
||||
...getHistogramColumn(HISTOGRAM_COLUMN_NAME, this.dataView.timeFieldName ?? '@timestamp'),
|
||||
},
|
||||
};
|
||||
|
||||
const dataLayer = this.formula.insertOrReplaceFormulaColumn(
|
||||
'y_cpu_cores_usage',
|
||||
{
|
||||
formula: 'average(system.load.1) / max(system.load.cores)',
|
||||
format: {
|
||||
id: 'percent',
|
||||
params: {
|
||||
decimals: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
baseLayer,
|
||||
this.dataView
|
||||
);
|
||||
|
||||
if (!dataLayer) {
|
||||
throw new Error('Error generating the data layer for the chart');
|
||||
}
|
||||
|
||||
return {
|
||||
[DEFAULT_LAYER_ID]: dataLayer,
|
||||
referenceLayer: {
|
||||
linkToLayers: [],
|
||||
columnOrder: ['referenceColumn'],
|
||||
columns: {
|
||||
referenceColumn: {
|
||||
label: 'Reference',
|
||||
dataType: 'number',
|
||||
operationType: 'static_value',
|
||||
isStaticValue: true,
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
params: {
|
||||
value: 1,
|
||||
format: {
|
||||
id: 'percent',
|
||||
params: {
|
||||
decimals: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
references: [],
|
||||
customLabel: true,
|
||||
} as ReferenceBasedIndexPatternColumn,
|
||||
},
|
||||
sampling: 1,
|
||||
incompleteColumns: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
getVisualizationState = (): XYState => {
|
||||
return getXYVisualizationState({
|
||||
layers: [
|
||||
{
|
||||
layerId: DEFAULT_LAYER_ID,
|
||||
seriesType: 'line',
|
||||
accessors: ['y_cpu_cores_usage'],
|
||||
yConfig: [],
|
||||
layerType: 'data',
|
||||
xAccessor: HISTOGRAM_COLUMN_NAME,
|
||||
splitAccessor: BREAKDOWN_COLUMN_NAME,
|
||||
},
|
||||
{
|
||||
layerId: REFERENCE_LAYER,
|
||||
layerType: 'referenceLine',
|
||||
accessors: ['referenceColumn'],
|
||||
yConfig: [
|
||||
{
|
||||
forAccessor: 'referenceColumn',
|
||||
axisMode: 'left',
|
||||
color: '#6092c0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
getFilters = (): Filter[] => {
|
||||
return [
|
||||
{
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: 'c1ec8212-ecee-494a-80da-f6f33b3393f2',
|
||||
key: 'system.load.cores',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.load.cores',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: 'c1ec8212-ecee-494a-80da-f6f33b3393f2',
|
||||
key: 'system.load.1',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.load.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
getReferences = (): SavedObjectReference[] => {
|
||||
return [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${DEFAULT_LAYER_ID}`,
|
||||
},
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${REFERENCE_LAYER}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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 Filter, FilterStateStore } from '@kbn/es-query';
|
||||
import type {
|
||||
FormBasedLayer,
|
||||
FormulaPublicApi,
|
||||
PersistedIndexPatternLayer,
|
||||
XYState,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import {
|
||||
DEFAULT_LAYER_ID,
|
||||
getBreakdownColumn,
|
||||
getHistogramColumn,
|
||||
getXYVisualizationState,
|
||||
} from '../utils';
|
||||
import type { LensOptions } from '../../../../types';
|
||||
import { ILensVisualization } from '../types';
|
||||
|
||||
const BREAKDOWN_COLUMN_NAME = 'hosts_aggs_breakdown';
|
||||
const HISTOGRAM_COLUMN_NAME = 'x_date_histogram';
|
||||
|
||||
export class Memory implements ILensVisualization {
|
||||
constructor(
|
||||
private dataView: DataView,
|
||||
private options: LensOptions,
|
||||
private formula: FormulaPublicApi
|
||||
) {}
|
||||
|
||||
getTitle(): string {
|
||||
return 'Disk Writes IOPS';
|
||||
}
|
||||
|
||||
getVisualizationType(): string {
|
||||
return 'lnsXY';
|
||||
}
|
||||
|
||||
getLayers = (): Record<string, Omit<FormBasedLayer, 'indexPatternId'>> => {
|
||||
const baseLayer: PersistedIndexPatternLayer = {
|
||||
columnOrder: [BREAKDOWN_COLUMN_NAME, HISTOGRAM_COLUMN_NAME],
|
||||
columns: {
|
||||
...getBreakdownColumn(BREAKDOWN_COLUMN_NAME, 'host.name', this.options.breakdownSize),
|
||||
...getHistogramColumn(HISTOGRAM_COLUMN_NAME, this.dataView.timeFieldName ?? '@timestamp'),
|
||||
},
|
||||
};
|
||||
|
||||
const dataLayer = this.formula.insertOrReplaceFormulaColumn(
|
||||
'y_memory_usage',
|
||||
{
|
||||
formula: 'average(system.memory.actual.used.bytes) / max(system.memory.total)',
|
||||
format: {
|
||||
id: 'percent',
|
||||
params: {
|
||||
decimals: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
baseLayer,
|
||||
this.dataView
|
||||
);
|
||||
|
||||
if (!dataLayer) {
|
||||
throw new Error('Error generating the data layer for the chart');
|
||||
}
|
||||
|
||||
return { [DEFAULT_LAYER_ID]: dataLayer };
|
||||
};
|
||||
getVisualizationState = (): XYState => {
|
||||
return getXYVisualizationState({
|
||||
layers: [
|
||||
{
|
||||
layerId: DEFAULT_LAYER_ID,
|
||||
seriesType: 'line',
|
||||
accessors: ['y_memory_usage'],
|
||||
yConfig: [],
|
||||
layerType: 'data',
|
||||
xAccessor: HISTOGRAM_COLUMN_NAME,
|
||||
splitAccessor: BREAKDOWN_COLUMN_NAME,
|
||||
},
|
||||
],
|
||||
yLeftExtent: {
|
||||
mode: 'custom',
|
||||
lowerBound: 0,
|
||||
upperBound: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
getFilters = (): Filter[] => {
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: '3be1e71b-4bc5-4462-a314-04539f877a19',
|
||||
key: 'system.memory.actual.used.bytes',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.memory.actual.used.bytes',
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: '3be1e71b-4bc5-4462-a314-04539f877a19',
|
||||
key: 'system.memory.total',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.memory.total',
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
getReferences = (): SavedObjectReference[] => {
|
||||
return [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${DEFAULT_LAYER_ID}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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 Filter, FilterStateStore } from '@kbn/es-query';
|
||||
import type {
|
||||
FormBasedLayer,
|
||||
FormulaPublicApi,
|
||||
PersistedIndexPatternLayer,
|
||||
XYState,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import {
|
||||
DEFAULT_LAYER_ID,
|
||||
getBreakdownColumn,
|
||||
getHistogramColumn,
|
||||
getXYVisualizationState,
|
||||
} from '../utils';
|
||||
import type { LensOptions } from '../../../../types';
|
||||
import { ILensVisualization } from '../types';
|
||||
|
||||
const BREAKDOWN_COLUMN_NAME = 'hosts_aggs_breakdown';
|
||||
const HISTOGRAM_COLUMN_NAME = 'x_date_histogram';
|
||||
|
||||
export class RX implements ILensVisualization {
|
||||
constructor(
|
||||
private dataView: DataView,
|
||||
private options: LensOptions,
|
||||
private formula: FormulaPublicApi
|
||||
) {}
|
||||
|
||||
getTitle(): string {
|
||||
return 'Network Inbound (RX)';
|
||||
}
|
||||
|
||||
getVisualizationType(): string {
|
||||
return 'lnsXY';
|
||||
}
|
||||
|
||||
getLayers = (): Record<string, Omit<FormBasedLayer, 'indexPatternId'>> => {
|
||||
const baseLayer: PersistedIndexPatternLayer = {
|
||||
columnOrder: [BREAKDOWN_COLUMN_NAME, HISTOGRAM_COLUMN_NAME],
|
||||
columns: {
|
||||
...getBreakdownColumn(BREAKDOWN_COLUMN_NAME, 'host.name', this.options.breakdownSize),
|
||||
...getHistogramColumn(HISTOGRAM_COLUMN_NAME, this.dataView.timeFieldName ?? '@timestamp'),
|
||||
},
|
||||
};
|
||||
|
||||
const dataLayer = this.formula.insertOrReplaceFormulaColumn(
|
||||
'y_network_in_bytes',
|
||||
{
|
||||
formula: "counter_rate(max(system.network.in.bytes), kql='system.network.in.bytes: *') * 8",
|
||||
timeScale: 's',
|
||||
format: {
|
||||
id: 'bits',
|
||||
params: {
|
||||
decimals: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
baseLayer,
|
||||
this.dataView
|
||||
);
|
||||
|
||||
if (!dataLayer) {
|
||||
throw new Error('Error generating the data layer for the chart');
|
||||
}
|
||||
|
||||
return { [DEFAULT_LAYER_ID]: dataLayer };
|
||||
};
|
||||
getVisualizationState = (): XYState => {
|
||||
return getXYVisualizationState({
|
||||
layers: [
|
||||
{
|
||||
layerId: DEFAULT_LAYER_ID,
|
||||
seriesType: 'line',
|
||||
accessors: ['y_network_in_bytes'],
|
||||
yConfig: [],
|
||||
layerType: 'data',
|
||||
xAccessor: HISTOGRAM_COLUMN_NAME,
|
||||
splitAccessor: BREAKDOWN_COLUMN_NAME,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
getFilters = (): Filter[] => {
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: '3be1e71b-4bc5-4462-a314-04539f877a19',
|
||||
key: 'system.network.in.bytes',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.network.in.bytes',
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
getReferences = (): SavedObjectReference[] => {
|
||||
return [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${DEFAULT_LAYER_ID}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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 Filter, FilterStateStore } from '@kbn/es-query';
|
||||
import type {
|
||||
FormBasedLayer,
|
||||
FormulaPublicApi,
|
||||
PersistedIndexPatternLayer,
|
||||
XYState,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import {
|
||||
DEFAULT_LAYER_ID,
|
||||
getBreakdownColumn,
|
||||
getHistogramColumn,
|
||||
getXYVisualizationState,
|
||||
} from '../utils';
|
||||
import type { LensOptions } from '../../../../types';
|
||||
import { ILensVisualization } from '../types';
|
||||
|
||||
const BREAKDOWN_COLUMN_NAME = 'hosts_aggs_breakdown';
|
||||
const HISTOGRAM_COLUMN_NAME = 'x_date_histogram';
|
||||
|
||||
export class TX implements ILensVisualization {
|
||||
constructor(
|
||||
private dataView: DataView,
|
||||
private options: LensOptions,
|
||||
private formula: FormulaPublicApi
|
||||
) {}
|
||||
|
||||
getTitle(): string {
|
||||
return 'Network Outbound (TX)';
|
||||
}
|
||||
|
||||
getVisualizationType(): string {
|
||||
return 'lnsXY';
|
||||
}
|
||||
|
||||
getLayers = (): Record<string, Omit<FormBasedLayer, 'indexPatternId'>> => {
|
||||
const baseLayer: PersistedIndexPatternLayer = {
|
||||
columnOrder: [BREAKDOWN_COLUMN_NAME, HISTOGRAM_COLUMN_NAME],
|
||||
columns: {
|
||||
...getBreakdownColumn(BREAKDOWN_COLUMN_NAME, 'host.name', this.options.breakdownSize),
|
||||
...getHistogramColumn(HISTOGRAM_COLUMN_NAME, this.dataView.timeFieldName ?? '@timestamp'),
|
||||
},
|
||||
};
|
||||
|
||||
const dataLayer = this.formula.insertOrReplaceFormulaColumn(
|
||||
'y_network_out_bytes',
|
||||
{
|
||||
formula:
|
||||
"counter_rate(max(system.network.out.bytes), kql='system.network.out.bytes: *') * 8",
|
||||
timeScale: 's',
|
||||
format: {
|
||||
id: 'bits',
|
||||
params: {
|
||||
decimals: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
baseLayer,
|
||||
this.dataView
|
||||
);
|
||||
|
||||
if (!dataLayer) {
|
||||
throw new Error('Error generating the data layer for the chart');
|
||||
}
|
||||
|
||||
return { [DEFAULT_LAYER_ID]: dataLayer };
|
||||
};
|
||||
getVisualizationState = (): XYState => {
|
||||
return getXYVisualizationState({
|
||||
layers: [
|
||||
{
|
||||
layerId: DEFAULT_LAYER_ID,
|
||||
seriesType: 'line',
|
||||
accessors: ['y_network_out_bytes'],
|
||||
yConfig: [],
|
||||
layerType: 'data',
|
||||
xAccessor: HISTOGRAM_COLUMN_NAME,
|
||||
splitAccessor: BREAKDOWN_COLUMN_NAME,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
getFilters = (): Filter[] => {
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: '3be1e71b-4bc5-4462-a314-04539f877a19',
|
||||
key: 'system.network.out.bytes',
|
||||
value: 'exists',
|
||||
type: 'exists',
|
||||
},
|
||||
query: {
|
||||
exists: {
|
||||
field: 'system.network.out.bytes',
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
getReferences = (): SavedObjectReference[] => {
|
||||
return [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: this.dataView.id ?? '',
|
||||
name: `indexpattern-datasource-layer-${DEFAULT_LAYER_ID}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 { LensAttributes } from '../../../types';
|
||||
import { ILensVisualization } from './types';
|
||||
|
||||
export const buildLensAttributes = (visualization: ILensVisualization): LensAttributes => {
|
||||
return {
|
||||
title: visualization.getTitle(),
|
||||
visualizationType: visualization.getVisualizationType(),
|
||||
references: visualization.getReferences(),
|
||||
state: {
|
||||
datasourceStates: {
|
||||
formBased: {
|
||||
layers: visualization.getLayers(),
|
||||
},
|
||||
},
|
||||
filters: visualization.getFilters(),
|
||||
query: { language: 'kuery', query: '' },
|
||||
visualization: visualization.getVisualizationState(),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { FormBasedLayer, XYState } from '@kbn/lens-plugin/public';
|
||||
|
||||
export interface ILensVisualization {
|
||||
getTitle(): string;
|
||||
getVisualizationType(): string;
|
||||
getLayers(): Record<string, Omit<FormBasedLayer, 'indexPatternId'>>;
|
||||
getVisualizationState(): XYState;
|
||||
getFilters(): Filter[];
|
||||
getReferences(): SavedObjectReference[];
|
||||
}
|
103
x-pack/plugins/infra/public/common/visualizations/lens/utils.ts
Normal file
103
x-pack/plugins/infra/public/common/visualizations/lens/utils.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 {
|
||||
DateHistogramIndexPatternColumn,
|
||||
PersistedIndexPatternLayer,
|
||||
TermsIndexPatternColumn,
|
||||
XYState,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
|
||||
export const DEFAULT_LAYER_ID = 'layer1';
|
||||
|
||||
export const getHistogramColumn = (columnName: string, sourceField: string) => {
|
||||
return {
|
||||
[columnName]: {
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '@timestamp',
|
||||
operationType: 'date_histogram',
|
||||
params: { interval: 'auto' },
|
||||
scale: 'interval',
|
||||
sourceField,
|
||||
} as DateHistogramIndexPatternColumn,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBreakdownColumn = (
|
||||
columnName: string,
|
||||
sourceField: string,
|
||||
breakdownSize: number
|
||||
): PersistedIndexPatternLayer['columns'] => {
|
||||
return {
|
||||
[columnName]: {
|
||||
label: `Top ${breakdownSize} values of ${sourceField}`,
|
||||
dataType: 'string',
|
||||
operationType: 'terms',
|
||||
scale: 'ordinal',
|
||||
sourceField,
|
||||
isBucketed: true,
|
||||
params: {
|
||||
size: breakdownSize,
|
||||
orderBy: {
|
||||
type: 'alphabetical',
|
||||
fallback: false,
|
||||
},
|
||||
orderDirection: 'desc',
|
||||
otherBucket: false,
|
||||
missingBucket: false,
|
||||
parentFormat: {
|
||||
id: 'terms',
|
||||
},
|
||||
include: [],
|
||||
exclude: [],
|
||||
includeIsRegex: false,
|
||||
excludeIsRegex: false,
|
||||
},
|
||||
} as TermsIndexPatternColumn,
|
||||
};
|
||||
};
|
||||
|
||||
export const getXYVisualizationState = (
|
||||
custom: Omit<Partial<XYState>, 'layers'> & { layers: XYState['layers'] }
|
||||
): XYState => ({
|
||||
legend: {
|
||||
isVisible: false,
|
||||
position: 'right',
|
||||
showSingleSeries: false,
|
||||
},
|
||||
valueLabels: 'show',
|
||||
fittingFunction: 'None',
|
||||
curveType: 'LINEAR',
|
||||
yLeftScale: 'linear',
|
||||
axisTitlesVisibilitySettings: {
|
||||
x: false,
|
||||
yLeft: false,
|
||||
yRight: true,
|
||||
},
|
||||
tickLabelsVisibilitySettings: {
|
||||
x: true,
|
||||
yLeft: true,
|
||||
yRight: true,
|
||||
},
|
||||
labelsOrientation: {
|
||||
x: 0,
|
||||
yLeft: 0,
|
||||
yRight: 0,
|
||||
},
|
||||
gridlinesVisibilitySettings: {
|
||||
x: true,
|
||||
yLeft: true,
|
||||
yRight: true,
|
||||
},
|
||||
preferredSeriesType: 'line',
|
||||
valuesInLegend: false,
|
||||
emphasizeFitting: true,
|
||||
yTitle: '',
|
||||
xTitle: '',
|
||||
hideEndzones: true,
|
||||
...custom,
|
||||
});
|
229
x-pack/plugins/infra/public/hooks/use_lens_attributes.test.ts
Normal file
229
x-pack/plugins/infra/public/hooks/use_lens_attributes.test.ts
Normal file
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* 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 'jest-canvas-mock';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useLensAttributes } from './use_lens_attributes';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { KibanaReactContextValue, useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { InfraClientStartDeps } from '../types';
|
||||
import { lensPluginMock } from '@kbn/lens-plugin/public/mocks';
|
||||
import { FilterStateStore } from '@kbn/es-query';
|
||||
|
||||
jest.mock('@kbn/kibana-react-plugin/public');
|
||||
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
|
||||
|
||||
const mockDataView = {
|
||||
id: 'mock-id',
|
||||
title: 'mock-title',
|
||||
timeFieldName: '@timestamp',
|
||||
isPersisted: () => false,
|
||||
getName: () => 'mock-data-view',
|
||||
toSpec: () => ({}),
|
||||
fields: [],
|
||||
metaFields: [],
|
||||
} as unknown as jest.Mocked<DataView>;
|
||||
|
||||
const lensPluginMockStart = lensPluginMock.createStartContract();
|
||||
const mockUseKibana = () => {
|
||||
useKibanaMock.mockReturnValue({
|
||||
services: {
|
||||
...coreMock.createStart(),
|
||||
lens: { ...lensPluginMockStart },
|
||||
} as Partial<CoreStart> & Partial<InfraClientStartDeps>,
|
||||
} as unknown as KibanaReactContextValue<Partial<CoreStart> & Partial<InfraClientStartDeps>>);
|
||||
};
|
||||
|
||||
describe('useHostTable hook', () => {
|
||||
beforeEach(() => {
|
||||
mockUseKibana();
|
||||
});
|
||||
|
||||
it('should return the basic lens attributes', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useLensAttributes({
|
||||
type: 'load',
|
||||
dataView: mockDataView,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const { state, title } = result.current.attributes ?? {};
|
||||
const { datasourceStates, filters } = state ?? {};
|
||||
|
||||
expect(title).toBe('Normalized Load');
|
||||
expect(datasourceStates).toEqual({
|
||||
formBased: {
|
||||
layers: {
|
||||
layer1: {
|
||||
columnOrder: ['hosts_aggs_breakdown', 'x_date_histogram', 'y_cpu_cores_usage'],
|
||||
columns: {
|
||||
hosts_aggs_breakdown: {
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
label: 'Top 10 values of host.name',
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
exclude: [],
|
||||
excludeIsRegex: false,
|
||||
include: [],
|
||||
includeIsRegex: false,
|
||||
missingBucket: false,
|
||||
orderBy: {
|
||||
fallback: false,
|
||||
type: 'alphabetical',
|
||||
},
|
||||
orderDirection: 'desc',
|
||||
otherBucket: false,
|
||||
parentFormat: {
|
||||
id: 'terms',
|
||||
},
|
||||
size: 10,
|
||||
},
|
||||
scale: 'ordinal',
|
||||
sourceField: 'host.name',
|
||||
},
|
||||
x_date_histogram: {
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '@timestamp',
|
||||
operationType: 'date_histogram',
|
||||
params: {
|
||||
interval: 'auto',
|
||||
},
|
||||
scale: 'interval',
|
||||
sourceField: '@timestamp',
|
||||
},
|
||||
y_cpu_cores_usage: {
|
||||
customLabel: false,
|
||||
dataType: 'number',
|
||||
filter: undefined,
|
||||
isBucketed: false,
|
||||
label: 'average(system.load.1) / max(system.load.cores)',
|
||||
operationType: 'formula',
|
||||
params: {
|
||||
format: {
|
||||
id: 'percent',
|
||||
params: {
|
||||
decimals: 0,
|
||||
},
|
||||
},
|
||||
formula: 'average(system.load.1) / max(system.load.cores)',
|
||||
isFormulaBroken: true,
|
||||
},
|
||||
reducedTimeRange: undefined,
|
||||
references: [],
|
||||
timeScale: undefined,
|
||||
},
|
||||
},
|
||||
indexPatternId: 'mock-id',
|
||||
},
|
||||
referenceLayer: {
|
||||
columnOrder: ['referenceColumn'],
|
||||
columns: {
|
||||
referenceColumn: {
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
isStaticValue: true,
|
||||
label: 'Reference',
|
||||
operationType: 'static_value',
|
||||
params: {
|
||||
format: {
|
||||
id: 'percent',
|
||||
params: {
|
||||
decimals: 0,
|
||||
},
|
||||
},
|
||||
value: 1,
|
||||
},
|
||||
references: [],
|
||||
scale: 'ratio',
|
||||
},
|
||||
},
|
||||
incompleteColumns: {},
|
||||
linkToLayers: [],
|
||||
sampling: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(filters).toEqual([
|
||||
{
|
||||
$state: { store: 'appState' },
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
index: 'c1ec8212-ecee-494a-80da-f6f33b3393f2',
|
||||
key: 'system.load.cores',
|
||||
negate: false,
|
||||
type: 'exists',
|
||||
value: 'exists',
|
||||
},
|
||||
query: { exists: { field: 'system.load.cores' } },
|
||||
},
|
||||
{
|
||||
$state: { store: 'appState' },
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
index: 'c1ec8212-ecee-494a-80da-f6f33b3393f2',
|
||||
key: 'system.load.1',
|
||||
negate: false,
|
||||
type: 'exists',
|
||||
value: 'exists',
|
||||
},
|
||||
query: { exists: { field: 'system.load.1' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return attributes with injected values', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useLensAttributes({
|
||||
type: 'load',
|
||||
dataView: mockDataView,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const injectedData = {
|
||||
query: {
|
||||
language: 'kuery',
|
||||
query: '{term: { host.name: "a"}}',
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
$state: { store: FilterStateStore.APP_STATE },
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
index: 'c1ec8212-ecee-494a-80da-f6f33b3393f2',
|
||||
key: 'system.load.cores',
|
||||
negate: false,
|
||||
type: 'range',
|
||||
value: 'range',
|
||||
},
|
||||
query: { range: { 'system.load.cores': { gte: 0 } } },
|
||||
},
|
||||
],
|
||||
title: 'Injected CPU Cores',
|
||||
};
|
||||
|
||||
const injectedAttributes = result.current.injectData(injectedData);
|
||||
|
||||
const { state, title } = injectedAttributes ?? {};
|
||||
const { filters, query } = state ?? {};
|
||||
|
||||
expect(title).toEqual(injectedData.title);
|
||||
expect(query).toEqual(injectedData.query);
|
||||
expect(filters).toHaveLength(3);
|
||||
expect(filters).toContain(injectedData.filters[0]);
|
||||
});
|
||||
});
|
115
x-pack/plugins/infra/public/hooks/use_lens_attributes.ts
Normal file
115
x-pack/plugins/infra/public/hooks/use_lens_attributes.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { InfraClientSetupDeps, LensAttributes, LensOptions } from '../types';
|
||||
import {
|
||||
buildLensAttributes,
|
||||
HostLensAttributesTypes,
|
||||
hostMetricsLensAttributes,
|
||||
} from '../common/visualizations';
|
||||
|
||||
interface UseLensAttributesParams {
|
||||
type: HostLensAttributesTypes;
|
||||
dataView: DataView | undefined;
|
||||
options?: LensOptions;
|
||||
}
|
||||
|
||||
export const useLensAttributes = ({
|
||||
type,
|
||||
dataView,
|
||||
options = {
|
||||
breakdownSize: 10,
|
||||
},
|
||||
}: UseLensAttributesParams) => {
|
||||
const {
|
||||
services: { lens },
|
||||
} = useKibana<InfraClientSetupDeps>();
|
||||
const { navigateToPrefilledEditor } = lens;
|
||||
const { value, error } = useAsync(lens.stateHelperApi, [lens]);
|
||||
const { formula: formulaAPI } = value ?? {};
|
||||
|
||||
const attributes: LensAttributes | null = useMemo(() => {
|
||||
if (!dataView || !formulaAPI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const VisualizationClass = hostMetricsLensAttributes[type];
|
||||
const visualizationAttributes = buildLensAttributes(
|
||||
new VisualizationClass(dataView, options, formulaAPI)
|
||||
);
|
||||
|
||||
return visualizationAttributes;
|
||||
}, [dataView, formulaAPI, options, type]);
|
||||
|
||||
const injectData = (data: {
|
||||
filters: Filter[];
|
||||
query: Query;
|
||||
title?: string;
|
||||
}): LensAttributes | null => {
|
||||
if (!attributes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
...(!!data.title ? { title: data.title } : {}),
|
||||
state: {
|
||||
...attributes.state,
|
||||
query: data.query,
|
||||
filters: [...attributes.state.filters, ...data.filters],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getExtraActions = (currentAttributes: LensAttributes | null, timeRange: TimeRange) => {
|
||||
return {
|
||||
openInLens: {
|
||||
id: 'openInLens',
|
||||
|
||||
getDisplayName(_context: ActionExecutionContext): string {
|
||||
return i18n.translate(
|
||||
'xpack.infra.hostsViewPage.tabs.metricsCharts.actions.openInLines',
|
||||
{
|
||||
defaultMessage: 'Open in Lens',
|
||||
}
|
||||
);
|
||||
},
|
||||
getIconType(_context: ActionExecutionContext): string | undefined {
|
||||
return 'visArea';
|
||||
},
|
||||
type: 'actionButton',
|
||||
async isCompatible(_context: ActionExecutionContext): Promise<boolean> {
|
||||
return true;
|
||||
},
|
||||
async execute(_context: ActionExecutionContext): Promise<void> {
|
||||
if (currentAttributes) {
|
||||
navigateToPrefilledEditor(
|
||||
{
|
||||
id: '',
|
||||
timeRange,
|
||||
attributes: currentAttributes,
|
||||
},
|
||||
{
|
||||
openInNewTab: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
order: 100,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return { attributes, injectData, getExtraActions, error };
|
||||
};
|
|
@ -16,10 +16,10 @@ export const ExperimentalBadge = () => (
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
`}
|
||||
label={i18n.translate('xpack.infra.hostsPage.experimentalBadgeLabel', {
|
||||
label={i18n.translate('xpack.infra.hostsViewPage.experimentalBadgeLabel', {
|
||||
defaultMessage: 'Technical preview',
|
||||
})}
|
||||
tooltipContent={i18n.translate('xpack.infra.hostsPage.experimentalBadgeDescription', {
|
||||
tooltipContent={i18n.translate('xpack.infra.hostsViewPage.experimentalBadgeDescription', {
|
||||
defaultMessage:
|
||||
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
|
||||
})}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { InfraLoadingPanel } from '../../../../components/loading';
|
||||
|
@ -13,7 +13,8 @@ import { useMetricsDataViewContext } from '../hooks/use_data_view';
|
|||
import { UnifiedSearchBar } from './unified_search_bar';
|
||||
import { HostsTable } from './hosts_table';
|
||||
import { HostsViewProvider } from '../hooks/use_hosts_view';
|
||||
import { MetricsTrend } from './metrics_trend/metrics_trend';
|
||||
import { KPICharts } from './kpi_charts/kpi_charts';
|
||||
import { Tabs } from './tabs/tabs';
|
||||
|
||||
export const HostContainer = () => {
|
||||
const { metricsDataView, isDataViewLoading, hasFailedLoadingDataView } =
|
||||
|
@ -36,9 +37,17 @@ export const HostContainer = () => {
|
|||
<UnifiedSearchBar dataView={metricsDataView} />
|
||||
<EuiSpacer />
|
||||
<HostsViewProvider>
|
||||
<MetricsTrend />
|
||||
<EuiSpacer />
|
||||
<HostsTable />
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<KPICharts />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<HostsTable />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<Tabs />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</HostsViewProvider>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -87,7 +87,7 @@ export const HostsTable = () => {
|
|||
if (loading) {
|
||||
return (
|
||||
<InfraLoadingPanel
|
||||
height="100%"
|
||||
height="185px"
|
||||
width="auto"
|
||||
text={i18n.translate('xpack.infra.waffle.loadingDataText', {
|
||||
defaultMessage: 'Loading data',
|
||||
|
|
|
@ -48,7 +48,7 @@ export const buildHostsTableColumns = ({
|
|||
|
||||
return [
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.nameColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.nameColumnHeader', {
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
field: 'title',
|
||||
|
@ -72,7 +72,7 @@ export const buildHostsTableColumns = ({
|
|||
),
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.operatingSystemColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.operatingSystemColumnHeader', {
|
||||
defaultMessage: 'Operating System',
|
||||
}),
|
||||
field: 'os',
|
||||
|
@ -80,7 +80,7 @@ export const buildHostsTableColumns = ({
|
|||
render: (os: string) => <EuiText size="s">{os}</EuiText>,
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.numberOfCpusColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.numberOfCpusColumnHeader', {
|
||||
defaultMessage: '# of CPUs',
|
||||
}),
|
||||
field: 'cpuCores',
|
||||
|
@ -91,7 +91,7 @@ export const buildHostsTableColumns = ({
|
|||
align: 'right',
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.diskLatencyColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.diskLatencyColumnHeader', {
|
||||
defaultMessage: 'Disk Latency (avg.)',
|
||||
}),
|
||||
field: 'diskLatency.avg',
|
||||
|
@ -100,7 +100,7 @@ export const buildHostsTableColumns = ({
|
|||
align: 'right',
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.averageTxColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.averageTxColumnHeader', {
|
||||
defaultMessage: 'TX (avg.)',
|
||||
}),
|
||||
field: 'tx.avg',
|
||||
|
@ -109,7 +109,7 @@ export const buildHostsTableColumns = ({
|
|||
align: 'right',
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.averageRxColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.averageRxColumnHeader', {
|
||||
defaultMessage: 'RX (avg.)',
|
||||
}),
|
||||
field: 'rx.avg',
|
||||
|
@ -118,7 +118,7 @@ export const buildHostsTableColumns = ({
|
|||
align: 'right',
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.averageMemoryTotalColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.averageMemoryTotalColumnHeader', {
|
||||
defaultMessage: 'Memory total (avg.)',
|
||||
}),
|
||||
field: 'memoryTotal.avg',
|
||||
|
@ -127,7 +127,7 @@ export const buildHostsTableColumns = ({
|
|||
align: 'right',
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.infra.hostsTable.averageMemoryUsageColumnHeader', {
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.table.averageMemoryUsageColumnHeader', {
|
||||
defaultMessage: 'Memory usage (avg.)',
|
||||
}),
|
||||
field: 'memory.avg',
|
||||
|
|
|
@ -7,13 +7,13 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useHostsViewContext } from '../../hooks/use_hosts_view';
|
||||
import { type ChartBaseProps, MetricsChart } from './metrics_chart';
|
||||
import { type ChartBaseProps, KPIChart } from './kpi_chart';
|
||||
|
||||
export const HostsTile = ({ type, ...props }: ChartBaseProps) => {
|
||||
const { hostViewState } = useHostsViewContext();
|
||||
|
||||
return (
|
||||
<MetricsChart
|
||||
<KPIChart
|
||||
id={`$metric-${type}`}
|
||||
type={type}
|
||||
nodes={[]}
|
|
@ -47,7 +47,7 @@ interface Props extends ChartBaseProps {
|
|||
|
||||
const MIN_HEIGHT = 150;
|
||||
|
||||
export const MetricsChart = ({
|
||||
export const KPIChart = ({
|
||||
color,
|
||||
extra,
|
||||
id,
|
||||
|
@ -109,16 +109,16 @@ export const MetricsChart = ({
|
|||
content={toolTip}
|
||||
anchorClassName="eui-fullWidth"
|
||||
>
|
||||
<ChartStyled size={{ height: MIN_HEIGHT }}>
|
||||
<KPIChartStyled size={{ height: MIN_HEIGHT }}>
|
||||
<Metric id={id} data={[[metricsData]]} />
|
||||
</ChartStyled>
|
||||
</KPIChartStyled>
|
||||
</EuiToolTip>
|
||||
)}
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartStyled = styled(Chart)`
|
||||
const KPIChartStyled = styled(Chart)`
|
||||
.echMetric {
|
||||
border-radius: 5px;
|
||||
}
|
|
@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { Tile } from './tile';
|
||||
import { HostsTile } from './hosts_tile';
|
||||
|
||||
export const MetricsTrend = () => {
|
||||
export const KPICharts = () => {
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
|
@ -26,16 +26,16 @@ export const MetricsTrend = () => {
|
|||
type="hostsCount"
|
||||
metricType="value"
|
||||
color="#6DCCB1"
|
||||
title={i18n.translate('xpack.infra.hostsTable.metricTrend.hostCount.title', {
|
||||
title={i18n.translate('xpack.infra.hostsViewPage.metricTrend.hostCount.title', {
|
||||
defaultMessage: 'Hosts',
|
||||
})}
|
||||
trendA11yTitle={i18n.translate(
|
||||
'xpack.infra.hostsTable.metricTrend.hostCount.a11y.title',
|
||||
'xpack.infra.hostsViewPage.metricTrend.hostCount.a11y.title',
|
||||
{
|
||||
defaultMessage: 'CPU usage over time.',
|
||||
}
|
||||
)}
|
||||
toolTip={i18n.translate('xpack.infra.hostsTable.metricTrend.hostCount.tooltip', {
|
||||
toolTip={i18n.translate('xpack.infra.hostsViewPage.metricTrend.hostCount.tooltip', {
|
||||
defaultMessage: 'The number of hosts returned by your current search criteria.',
|
||||
})}
|
||||
data-test-subj="hostsView-metricsTrend-hosts"
|
||||
|
@ -46,22 +46,22 @@ export const MetricsTrend = () => {
|
|||
type="cpu"
|
||||
metricType="avg"
|
||||
color="#F1D86F"
|
||||
title={i18n.translate('xpack.infra.hostsTable.metricTrend.cpu.title', {
|
||||
title={i18n.translate('xpack.infra.hostsViewPage.metricTrend.cpu.title', {
|
||||
defaultMessage: 'CPU usage',
|
||||
})}
|
||||
subtitle={i18n.translate('xpack.infra.hostsTable.metricTrend.cpu.subtitle', {
|
||||
subtitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.cpu.subtitle', {
|
||||
defaultMessage: 'Average',
|
||||
})}
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsTable.metricTrend.cpu.a11y.title', {
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.cpu.a11y.title', {
|
||||
defaultMessage: 'CPU usage over time.',
|
||||
})}
|
||||
trendA11yDescription={i18n.translate(
|
||||
'xpack.infra.hostsTable.metricTrend.cpu.a11y.description',
|
||||
'xpack.infra.hostsViewPage.metricTrend.cpu.a11y.description',
|
||||
{
|
||||
defaultMessage: 'A line chart showing the trend of the primary metric over time.',
|
||||
}
|
||||
)}
|
||||
toolTip={i18n.translate('xpack.infra.hostsTable.metricTrend.cpu.tooltip', {
|
||||
toolTip={i18n.translate('xpack.infra.hostsViewPage.metricTrend.cpu.tooltip', {
|
||||
defaultMessage:
|
||||
'Average of percentage of CPU time spent in states other than Idle and IOWait, normalized by the number of CPU cores. Includes both time spent on user space and kernel space. 100% means all CPUs of the host are busy.',
|
||||
})}
|
||||
|
@ -73,22 +73,22 @@ export const MetricsTrend = () => {
|
|||
type="memory"
|
||||
metricType="avg"
|
||||
color="#A987D1"
|
||||
title={i18n.translate('xpack.infra.hostsTable.metricTrend.memory.title', {
|
||||
title={i18n.translate('xpack.infra.hostsViewPage.metricTrend.memory.title', {
|
||||
defaultMessage: 'Memory usage',
|
||||
})}
|
||||
subtitle={i18n.translate('xpack.infra.hostsTable.metricTrend.memory.subtitle', {
|
||||
subtitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.memory.subtitle', {
|
||||
defaultMessage: 'Average',
|
||||
})}
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsTable.metricTrend.memory.a11yTitle', {
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.memory.a11yTitle', {
|
||||
defaultMessage: 'Memory usage over time.',
|
||||
})}
|
||||
trendA11yDescription={i18n.translate(
|
||||
'xpack.infra.hostsTable.metricTrend.memory.a11yDescription',
|
||||
'xpack.infra.hostsViewPage.metricTrend.memory.a11yDescription',
|
||||
{
|
||||
defaultMessage: 'A line chart showing the trend of the primary metric over time.',
|
||||
}
|
||||
)}
|
||||
toolTip={i18n.translate('xpack.infra.hostsTable.metricTrend.memory.tooltip', {
|
||||
toolTip={i18n.translate('xpack.infra.hostsViewPage.metricTrend.memory.tooltip', {
|
||||
defaultMessage:
|
||||
"Average of percentage of main memory usage excluding page cache. This includes resident memory for all processes plus memory used by the kernel structures and code apart the page cache. A high level indicates a situation of memory saturation for a host. 100% means the main memory is entirely filled with memory that can't be reclaimed, except by swapping out.",
|
||||
})}
|
||||
|
@ -100,22 +100,22 @@ export const MetricsTrend = () => {
|
|||
type="rx"
|
||||
metricType="avg"
|
||||
color="#79AAD9"
|
||||
title={i18n.translate('xpack.infra.hostsTable.metricTrend.rx.title', {
|
||||
title={i18n.translate('xpack.infra.hostsViewPage.metricTrend.rx.title', {
|
||||
defaultMessage: 'Network inbound (RX)',
|
||||
})}
|
||||
subtitle={i18n.translate('xpack.infra.hostsTable.metricTrend.rx.subtitle', {
|
||||
subtitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.rx.subtitle', {
|
||||
defaultMessage: 'Average',
|
||||
})}
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsTable.metricTrend.rx.a11y.title', {
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.rx.a11y.title', {
|
||||
defaultMessage: 'Network inbound (RX) over time.',
|
||||
})}
|
||||
trendA11yDescription={i18n.translate(
|
||||
'xpack.infra.hostsTable.metricTrend.rx.a11y.description',
|
||||
'xpack.infra.hostsViewPage.metricTrend.rx.a11y.description',
|
||||
{
|
||||
defaultMessage: 'A line chart showing the trend of the primary metric over time.',
|
||||
}
|
||||
)}
|
||||
toolTip={i18n.translate('xpack.infra.hostsTable.metricTrend.rx.tooltip', {
|
||||
toolTip={i18n.translate('xpack.infra.hostsViewPage.metricTrend.rx.tooltip', {
|
||||
defaultMessage:
|
||||
'Number of bytes which have been received per second on the public interfaces of the hosts.',
|
||||
})}
|
||||
|
@ -127,22 +127,22 @@ export const MetricsTrend = () => {
|
|||
type="tx"
|
||||
metricType="avg"
|
||||
color="#F5A35C"
|
||||
title={i18n.translate('xpack.infra.hostsTable.metricTrend.tx.title', {
|
||||
defaultMessage: 'Network outbound (TX) usage',
|
||||
title={i18n.translate('xpack.infra.hostsViewPage.metricTrend.tx.title', {
|
||||
defaultMessage: 'Network outbound (TX)',
|
||||
})}
|
||||
subtitle={i18n.translate('xpack.infra.hostsTable.metricTrend.tx.subtitle', {
|
||||
subtitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.tx.subtitle', {
|
||||
defaultMessage: 'Average',
|
||||
})}
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsTable.metricTrend.tx.a11.title', {
|
||||
trendA11yTitle={i18n.translate('xpack.infra.hostsViewPage.metricTrend.tx.a11.title', {
|
||||
defaultMessage: 'Network outbound (TX) usage over time.',
|
||||
})}
|
||||
trendA11yDescription={i18n.translate(
|
||||
'xpack.infra.hostsTable.metricTrend.tx.a11y.description',
|
||||
'xpack.infra.hostsViewPage.metricTrend.tx.a11y.description',
|
||||
{
|
||||
defaultMessage: 'A line chart showing the trend of the primary metric over time.',
|
||||
}
|
||||
)}
|
||||
toolTip={i18n.translate('xpack.infra.hostsTable.metricTrend.tx.tooltip', {
|
||||
toolTip={i18n.translate('xpack.infra.hostsViewPage.metricTrend.tx.tooltip', {
|
||||
defaultMessage:
|
||||
'Number of bytes which have been sent per second on the public interfaces of the hosts',
|
||||
})}
|
|
@ -9,7 +9,7 @@ import type { SnapshotMetricType } from '../../../../../../common/inventory_mode
|
|||
|
||||
import { useSnapshot } from '../../../inventory_view/hooks/use_snaphot';
|
||||
import { useHostsViewContext } from '../../hooks/use_hosts_view';
|
||||
import { type ChartBaseProps, MetricsChart } from './metrics_chart';
|
||||
import { type ChartBaseProps, KPIChart } from './kpi_chart';
|
||||
|
||||
interface Props extends Omit<ChartBaseProps, 'type'> {
|
||||
type: SnapshotMetricType;
|
||||
|
@ -24,7 +24,5 @@ export const Tile = ({ type, ...props }: Props) => {
|
|||
includeTimeseries: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<MetricsChart id={`$metric-${type}`} type={type} nodes={nodes} loading={loading} {...props} />
|
||||
);
|
||||
return <KPIChart id={`$metric-${type}`} type={type} nodes={nodes} loading={loading} {...props} />;
|
||||
};
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { Action } from '@kbn/ui-actions-plugin/public';
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { BrushTriggerEvent } from '@kbn/charts-plugin/public';
|
||||
import { EuiIcon, EuiPanel } from '@elastic/eui';
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
import { EuiFlexItem } from '@elastic/eui';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { EuiI18n } from '@elastic/eui';
|
||||
import { InfraClientSetupDeps } from '../../../../../../types';
|
||||
import { useLensAttributes } from '../../../../../../hooks/use_lens_attributes';
|
||||
import { useMetricsDataViewContext } from '../../../hooks/use_data_view';
|
||||
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
|
||||
import { HostLensAttributesTypes } from '../../../../../../common/visualizations';
|
||||
|
||||
export interface MetricChartProps {
|
||||
title: string;
|
||||
type: HostLensAttributesTypes;
|
||||
breakdownSize: number;
|
||||
}
|
||||
|
||||
const MIN_HEIGHT = 300;
|
||||
|
||||
export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) => {
|
||||
const {
|
||||
unifiedSearchDateRange,
|
||||
unifiedSearchQuery,
|
||||
unifiedSearchFilters,
|
||||
controlPanelFilters,
|
||||
onSubmit,
|
||||
} = useUnifiedSearchContext();
|
||||
const { metricsDataView } = useMetricsDataViewContext();
|
||||
const {
|
||||
services: { lens },
|
||||
} = useKibana<InfraClientSetupDeps>();
|
||||
|
||||
const EmbeddableComponent = lens.EmbeddableComponent;
|
||||
|
||||
const { injectData, getExtraActions, error } = useLensAttributes({
|
||||
type,
|
||||
dataView: metricsDataView,
|
||||
options: {
|
||||
breakdownSize,
|
||||
},
|
||||
});
|
||||
|
||||
const injectedLensAttributes = injectData({
|
||||
filters: [...unifiedSearchFilters, ...controlPanelFilters],
|
||||
query: unifiedSearchQuery,
|
||||
title,
|
||||
});
|
||||
|
||||
const extraActionOptions = getExtraActions(injectedLensAttributes, unifiedSearchDateRange);
|
||||
const extraAction: Action[] = [extraActionOptions.openInLens];
|
||||
|
||||
const handleBrushEnd = ({ range }: BrushTriggerEvent['data']) => {
|
||||
const [min, max] = range;
|
||||
onSubmit({
|
||||
dateRange: {
|
||||
from: new Date(min).toISOString(),
|
||||
to: new Date(max).toISOString(),
|
||||
mode: 'absolute',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
borderRadius="m"
|
||||
hasShadow={false}
|
||||
hasBorder
|
||||
paddingSize={error ? 'm' : 'none'}
|
||||
style={{ minHeight: MIN_HEIGHT }}
|
||||
data-test-subj={`hostsView-metricChart-${type}`}
|
||||
>
|
||||
{error ? (
|
||||
<EuiFlexGroup
|
||||
style={{ minHeight: MIN_HEIGHT, alignContent: 'center' }}
|
||||
gutterSize="xs"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
direction="column"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="alert" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s" textAlign="center">
|
||||
<EuiI18n
|
||||
token="'xpack.infra.hostsViewPage.errorOnLoadingLensDependencies'"
|
||||
default="There was an error trying to load Lens Plugin."
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
injectedLensAttributes && (
|
||||
<EmbeddableComponent
|
||||
id={`hostsViewsmetricsChart-${type}`}
|
||||
style={{ height: MIN_HEIGHT }}
|
||||
attributes={injectedLensAttributes}
|
||||
viewMode={ViewMode.VIEW}
|
||||
timeRange={unifiedSearchDateRange}
|
||||
query={unifiedSearchQuery}
|
||||
filters={unifiedSearchFilters}
|
||||
extraActions={extraAction}
|
||||
executionContext={{
|
||||
type: 'infrastructure_observability_hosts_view',
|
||||
name: `Hosts View ${type} Chart`,
|
||||
}}
|
||||
onBrushEnd={handleBrushEnd}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
|
||||
import { EuiFlexGrid, EuiFlexItem, EuiFlexGroup, EuiText, EuiI18n } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { MetricChart, MetricChartProps } from './metric_chart';
|
||||
|
||||
const DEFAULT_BREAKDOWN_SIZE = 20;
|
||||
const CHARTS_IN_ORDER: Array<Pick<MetricChartProps, 'title' | 'type'> & { fullRow?: boolean }> = [
|
||||
{
|
||||
title: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.load', {
|
||||
defaultMessage: 'Normalized Load',
|
||||
}),
|
||||
type: 'load',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.cpu', {
|
||||
defaultMessage: 'CPU Usage',
|
||||
}),
|
||||
type: 'cpu',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.memory', {
|
||||
defaultMessage: 'Memory Usage',
|
||||
}),
|
||||
type: 'memory',
|
||||
fullRow: true,
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.rx', {
|
||||
defaultMessage: 'Network Inbound (RX)',
|
||||
}),
|
||||
type: 'rx',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.tx', {
|
||||
defaultMessage: 'Network Outbound (TX)',
|
||||
}),
|
||||
type: 'tx',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.diskIORead', {
|
||||
defaultMessage: 'Disk Read IOPS',
|
||||
}),
|
||||
type: 'diskIORead',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.DiskIOWrite', {
|
||||
defaultMessage: 'Disk Write IOPS',
|
||||
}),
|
||||
type: 'diskIOWrite',
|
||||
},
|
||||
];
|
||||
|
||||
export const MetricsGrid = () => {
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s" data-test-subj="hostsView-metricChart">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false} style={{ flex: 1 }}>
|
||||
<EuiText size="xs">
|
||||
<EuiI18n
|
||||
token="xpack.infra.hostsViewPage.tabs.metricsCharts.sortingCriteria"
|
||||
default="Showing for Top {maxHosts} hosts by {attribute}"
|
||||
values={{
|
||||
maxHosts: <strong>{DEFAULT_BREAKDOWN_SIZE}</strong>,
|
||||
attribute: <strong>name</strong>,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGrid columns={2} gutterSize="s">
|
||||
{CHARTS_IN_ORDER.map(({ fullRow, ...chartProp }) => (
|
||||
<EuiFlexItem style={fullRow ? { gridColumn: '1/-1' } : {}}>
|
||||
<MetricChart breakdownSize={DEFAULT_BREAKDOWN_SIZE} {...chartProp} />
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGrid>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { EuiTabbedContent, EuiSpacer, type EuiTabbedContentTab } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { MetricsGrid } from './metrics/metrics_grid';
|
||||
|
||||
interface WrapperProps {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
const Wrapper = ({ children }: WrapperProps) => {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const Tabs = () => {
|
||||
const tabs: EuiTabbedContentTab[] = [
|
||||
{
|
||||
id: 'metrics',
|
||||
name: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.title', {
|
||||
defaultMessage: 'Metrics',
|
||||
}),
|
||||
'data-test-subj': 'hostsView-tabs-metrics',
|
||||
content: (
|
||||
<Wrapper>
|
||||
<MetricsGrid />
|
||||
</Wrapper>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} autoFocus="selected" />;
|
||||
};
|
|
@ -23,7 +23,7 @@ interface Props {
|
|||
|
||||
export const UnifiedSearchBar = ({ dataView }: Props) => {
|
||||
const {
|
||||
services: { unifiedSearch },
|
||||
services: { unifiedSearch, application },
|
||||
} = useKibana<InfraClientStartDeps>();
|
||||
const {
|
||||
unifiedSearchDateRange,
|
||||
|
@ -37,10 +37,6 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
|
|||
|
||||
const { SearchBar } = unifiedSearch.ui;
|
||||
|
||||
const onFilterChange = (filters: Filter[]) => {
|
||||
onQueryChange({ filters });
|
||||
};
|
||||
|
||||
const onQuerySubmit = (payload: { dateRange: TimeRange; query?: Query }) => {
|
||||
onQueryChange({ payload });
|
||||
};
|
||||
|
@ -62,14 +58,12 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
|
|||
|
||||
const onQueryChange = ({
|
||||
payload,
|
||||
filters,
|
||||
panelFilters,
|
||||
}: {
|
||||
payload?: { dateRange: TimeRange; query?: Query };
|
||||
filters?: Filter[];
|
||||
panelFilters?: Filter[];
|
||||
}) => {
|
||||
onSubmit({ query: payload?.query, dateRange: payload?.dateRange, filters, panelFilters });
|
||||
onSubmit({ query: payload?.query, dateRange: payload?.dateRange, panelFilters });
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -83,14 +77,12 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
|
|||
query={unifiedSearchQuery}
|
||||
dateRangeFrom={unifiedSearchDateRange.from}
|
||||
dateRangeTo={unifiedSearchDateRange.to}
|
||||
filters={unifiedSearchFilters}
|
||||
onQuerySubmit={onQuerySubmit}
|
||||
onSaved={onQuerySave}
|
||||
onSavedQueryUpdated={onQuerySave}
|
||||
onClearSavedQuery={onClearSavedQuery}
|
||||
showSaveQuery
|
||||
showSaveQuery={Boolean(application?.capabilities?.visualize?.saveQuery)}
|
||||
showQueryInput
|
||||
onFiltersUpdated={onFilterChange}
|
||||
displayStyle="inPage"
|
||||
/>
|
||||
<ControlsContent
|
||||
|
|
|
@ -9,8 +9,7 @@ import { useDataView } from './use_data_view';
|
|||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { KibanaReactContextValue, useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { coreMock, notificationServiceMock } from '@kbn/core/public/mocks';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types';
|
||||
import type { DataView, DataViewsServicePublic } from '@kbn/data-views-plugin/public';
|
||||
import { InfraClientStartDeps } from '../../../../types';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
|
||||
|
|
|
@ -76,7 +76,7 @@ export const useDataView = ({ metricAlias }: { metricAlias: string }) => {
|
|||
useEffect(() => {
|
||||
if (hasFailedLoadingDataView && notifications) {
|
||||
notifications.toasts.addDanger(
|
||||
i18n.translate('xpack.infra.hostsTable.errorOnCreateOrLoadDataview', {
|
||||
i18n.translate('xpack.infra.hostsViewPage.errorOnCreateOrLoadDataview', {
|
||||
defaultMessage:
|
||||
'There was an error trying to load or create the Data View: {metricAlias}',
|
||||
values: { metricAlias },
|
||||
|
|
|
@ -6,14 +6,15 @@
|
|||
*/
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import createContainer from 'constate';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { buildEsQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { debounce } from 'lodash';
|
||||
import type { SavedQuery } from '@kbn/data-plugin/public';
|
||||
import { debounce } from 'lodash';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import type { InfraClientStartDeps } from '../../../../types';
|
||||
import { useMetricsDataViewContext } from './use_data_view';
|
||||
import { useSyncKibanaTimeFilterTime } from '../../../../hooks/use_kibana_timefilter_time';
|
||||
import { useHostsUrlState, INITIAL_DATE_RANGE } from './use_hosts_url_state';
|
||||
import { useHostsUrlState, INITIAL_DATE_RANGE } from './use_unified_search_url_state';
|
||||
|
||||
export const useUnifiedSearch = () => {
|
||||
const { state, dispatch, getRangeInTimestamp, getTime } = useHostsUrlState();
|
||||
|
@ -30,6 +31,37 @@ export const useUnifiedSearch = () => {
|
|||
|
||||
const { filterManager } = queryManager;
|
||||
|
||||
useEffect(() => {
|
||||
const { filters } = state;
|
||||
if (!deepEqual(filters, filterManager.getFilters())) {
|
||||
filterManager.setFilters(filters);
|
||||
}
|
||||
}, [filterManager, state]);
|
||||
|
||||
// This will listen and react to all changes in filterManager and timefilter values,
|
||||
// to allow other components in the page to communicate with the unified search
|
||||
useEffect(() => {
|
||||
const next = () => {
|
||||
const globalFilters = filterManager.getFilters();
|
||||
debounceOnSubmit({
|
||||
filters: globalFilters,
|
||||
dateRange: getTime(),
|
||||
});
|
||||
};
|
||||
|
||||
const filterSubscription = filterManager.getUpdates$().subscribe({
|
||||
next,
|
||||
});
|
||||
const timeSubscription = queryManager.timefilter.timefilter.getTimeUpdate$().subscribe({
|
||||
next,
|
||||
});
|
||||
|
||||
return () => {
|
||||
filterSubscription.unsubscribe();
|
||||
timeSubscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data?: {
|
||||
query?: Query;
|
||||
|
@ -40,22 +72,18 @@ export const useUnifiedSearch = () => {
|
|||
const { query, dateRange, filters, panelFilters } = data ?? {};
|
||||
const newDateRange = dateRange ?? getTime();
|
||||
|
||||
if (filters) {
|
||||
filterManager.setFilters(filters);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'setQuery',
|
||||
payload: {
|
||||
query,
|
||||
filters: filters ? filterManager.getFilters() : undefined,
|
||||
filters,
|
||||
dateRange: newDateRange,
|
||||
dateRangeTimestamp: getRangeInTimestamp(newDateRange),
|
||||
panelFilters,
|
||||
},
|
||||
});
|
||||
},
|
||||
[getTime, dispatch, filterManager, getRangeInTimestamp]
|
||||
[getTime, dispatch, getRangeInTimestamp]
|
||||
);
|
||||
|
||||
// This won't prevent onSubmit from being fired twice when `clear filters` is clicked,
|
||||
|
@ -107,7 +135,7 @@ export const useUnifiedSearch = () => {
|
|||
onSubmit: debounceOnSubmit,
|
||||
saveQuery,
|
||||
unifiedSearchQuery: state.query,
|
||||
unifiedSearchDateRange: getTime(),
|
||||
unifiedSearchDateRange: state.dateRange,
|
||||
unifiedSearchFilters: state.filters,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -73,8 +73,9 @@ const reducer = (state: HostsState, action: Action): HostsState => {
|
|||
};
|
||||
|
||||
export const useHostsUrlState = () => {
|
||||
const [getTime] = useKibanaTimefilterTime(INITIAL_DATE_RANGE);
|
||||
const [urlState, setUrlState] = useUrlState<HostsState>({
|
||||
defaultState: INITIAL_HOSTS_STATE,
|
||||
defaultState: { ...INITIAL_HOSTS_STATE, dateRange: getTime() },
|
||||
decodeUrlState,
|
||||
encodeUrlState,
|
||||
urlStateKey: '_a',
|
||||
|
@ -83,8 +84,6 @@ export const useHostsUrlState = () => {
|
|||
|
||||
const [state, dispatch] = useReducer(reducer, urlState);
|
||||
|
||||
const [getTime] = useKibanaTimefilterTime(INITIAL_DATE_RANGE);
|
||||
|
||||
const getRangeInTimestamp = useCallback(({ from, to }: TimeRange) => {
|
||||
const fromTS = DateMath.parse(from)?.valueOf() ?? CALCULATED_DATE_RANGE_FROM;
|
||||
const toTS = DateMath.parse(to)?.valueOf() ?? CALCULATED_DATE_RANGE_TO;
|
|
@ -28,8 +28,9 @@ import type {
|
|||
} from '@kbn/observability-plugin/public';
|
||||
// import type { OsqueryPluginStart } from '../../osquery/public';
|
||||
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
|
||||
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
|
||||
import { UnwrapPromise } from '../common/utility_types';
|
||||
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
|
||||
import { type TypedLensByValueInput, LensPublicStart } from '@kbn/lens-plugin/public';
|
||||
import type { UnwrapPromise } from '../common/utility_types';
|
||||
import type {
|
||||
SourceProviderProps,
|
||||
UseNodeMetricsTableOptions,
|
||||
|
@ -62,6 +63,7 @@ export interface InfraClientSetupDeps {
|
|||
ml: MlPluginSetup;
|
||||
embeddable: EmbeddableSetup;
|
||||
share: SharePluginSetup;
|
||||
lens: LensPublicStart;
|
||||
}
|
||||
|
||||
export interface InfraClientStartDeps {
|
||||
|
@ -77,6 +79,7 @@ export interface InfraClientStartDeps {
|
|||
osquery?: unknown; // OsqueryPluginStart;
|
||||
share: SharePluginStart;
|
||||
storage: IStorageWrapper;
|
||||
lens: LensPublicStart;
|
||||
}
|
||||
|
||||
export type InfraClientCoreSetup = CoreSetup<InfraClientStartDeps, InfraClientStartExports>;
|
||||
|
@ -96,3 +99,9 @@ export interface InfraHttpError extends IHttpFetchError {
|
|||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LensAttributes = TypedLensByValueInput['attributes'];
|
||||
|
||||
export interface LensOptions {
|
||||
breakdownSize: number;
|
||||
}
|
||||
|
|
|
@ -1,15 +1,9 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
"../../../typings/**/*",
|
||||
"common/**/*",
|
||||
"public/**/*",
|
||||
"server/**/*",
|
||||
"types/**/*"
|
||||
],
|
||||
"include": ["../../../typings/**/*", "common/**/*", "public/**/*", "server/**/*", "types/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/data-plugin",
|
||||
|
@ -53,10 +47,12 @@
|
|||
"@kbn/logging-mocks",
|
||||
"@kbn/field-types",
|
||||
"@kbn/es-types",
|
||||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/charts-plugin",
|
||||
"@kbn/lens-plugin",
|
||||
"@kbn/core-saved-objects-common",
|
||||
"@kbn/core-analytics-server",
|
||||
"@kbn/analytics-client",
|
||||
"@kbn/analytics-client"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
]
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -38,4 +38,4 @@ metricbeat.modules:
|
|||
output.elasticsearch:
|
||||
hosts: [ "host.docker.internal:9200" ]
|
||||
username: "elastic"
|
||||
password: "changeme"
|
||||
password: "changeme"
|
|
@ -16290,7 +16290,6 @@
|
|||
"xpack.infra.deprecations.tiebreakerAdjustIndexing": "Ajustez votre indexation pour utiliser \"{field}\" comme moyen de départager.",
|
||||
"xpack.infra.deprecations.timestampAdjustIndexing": "Ajustez votre indexation pour utiliser \"{field}\" comme horodatage.",
|
||||
"xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "Dernières {duration} de données pour l'heure sélectionnée",
|
||||
"xpack.infra.hostsTable.errorOnCreateOrLoadDataview": "Une erreur s'est produite lors du chargement ou de la création de la vue de données : {metricAlias}",
|
||||
"xpack.infra.inventoryTimeline.header": "{metricLabel} moyen",
|
||||
"xpack.infra.kibanaMetrics.cloudIdMissingErrorMessage": "Le modèle de {metricId} nécessite un cloudId, mais aucun n'a été attribué à {nodeId}.",
|
||||
"xpack.infra.kibanaMetrics.invalidInfraMetricErrorMessage": "{id} n'est pas une valeur inframétrique valide",
|
||||
|
@ -16498,16 +16497,27 @@
|
|||
"xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "Voir les instructions de configuration",
|
||||
"xpack.infra.homePage.settingsTabTitle": "Paramètres",
|
||||
"xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "Rechercher des données d'infrastructure… (par exemple host.name:host-1)",
|
||||
"xpack.infra.hostsPage.experimentalBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.",
|
||||
"xpack.infra.hostsPage.experimentalBadgeLabel": "Version d'évaluation technique",
|
||||
"xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "Total de la mémoire (moy.)",
|
||||
"xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "Utilisation de la mémoire (moy.)",
|
||||
"xpack.infra.hostsTable.averageRxColumnHeader": "RX (moy.)",
|
||||
"xpack.infra.hostsTable.averageTxColumnHeader": "TX (moy.)",
|
||||
"xpack.infra.hostsTable.diskLatencyColumnHeader": "Latence du disque (moy.)",
|
||||
"xpack.infra.hostsTable.nameColumnHeader": "Nom",
|
||||
"xpack.infra.hostsTable.numberOfCpusColumnHeader": "Nombre de processeurs",
|
||||
"xpack.infra.hostsTable.operatingSystemColumnHeader": "Système d'exploitation",
|
||||
"xpack.infra.hostsViewPage.errorOnCreateOrLoadDataview": "Une erreur s'est produite lors du chargement ou de la création de la vue de données : {metricAlias}",
|
||||
"xpack.infra.hostsViewPage.experimentalBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.",
|
||||
"xpack.infra.hostsViewPage.experimentalBadgeLabel": "Version d'évaluation technique",
|
||||
"xpack.infra.hostsViewPage.metricTrend.cpu.title": "Utilisation CPU",
|
||||
"xpack.infra.hostsViewPage.metricTrend.memory.title": "Utilisation mémoire",
|
||||
"xpack.infra.hostsViewPage.metricTrend.rx.title": "Entrant (RX)",
|
||||
"xpack.infra.hostsViewPage.metricTrend.tx.title": "Sortant (TX)",
|
||||
"xpack.infra.hostsViewPage.table.averageMemoryTotalColumnHeader": "Total de la mémoire (moy.)",
|
||||
"xpack.infra.hostsViewPage.table.averageMemoryUsageColumnHeader": "Utilisation de la mémoire (moy.)",
|
||||
"xpack.infra.hostsViewPage.table.averageRxColumnHeader": "RX (moy.)",
|
||||
"xpack.infra.hostsViewPage.table.averageTxColumnHeader": "TX (moy.)",
|
||||
"xpack.infra.hostsViewPage.table.diskLatencyColumnHeader": "Latence du disque (moy.)",
|
||||
"xpack.infra.hostsViewPage.table.nameColumnHeader": "Nom",
|
||||
"xpack.infra.hostsViewPage.table.numberOfCpusColumnHeader": "Nombre de processeurs",
|
||||
"xpack.infra.hostsViewPage.table.operatingSystemColumnHeader": "Système d'exploitation",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.actions.openInLines": "Ouvrir dans Lens",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.title": "Indicateurs",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.cpu": "Utilisation CPU",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.memory": "Utilisation mémoire",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.rx": "Entrant (RX)",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "Sortant (TX)",
|
||||
"xpack.infra.infra.nodeDetails.apmTabLabel": "APM",
|
||||
"xpack.infra.infra.nodeDetails.createAlertLink": "Créer une règle d'inventaire",
|
||||
"xpack.infra.infra.nodeDetails.openAsPage": "Ouvrir en tant que page",
|
||||
|
@ -36581,4 +36591,4 @@
|
|||
"xpack.painlessLab.title": "Painless Lab",
|
||||
"xpack.painlessLab.walkthroughButtonLabel": "Présentation"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16277,7 +16277,6 @@
|
|||
"xpack.infra.deprecations.tiebreakerAdjustIndexing": "インデックスを調整し、\"{field}\"をタイブレーカーとして使用します。",
|
||||
"xpack.infra.deprecations.timestampAdjustIndexing": "インデックスを調整し、\"{field}\"をタイムスタンプとして使用します。",
|
||||
"xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "指定期間のデータの最後の{duration}",
|
||||
"xpack.infra.hostsTable.errorOnCreateOrLoadDataview": "データビューの読み込みまたは作成中にエラーが発生しました:{metricAlias}",
|
||||
"xpack.infra.inventoryTimeline.header": "平均{metricLabel}",
|
||||
"xpack.infra.kibanaMetrics.cloudIdMissingErrorMessage": "{metricId} のモデルには cloudId が必要ですが、{nodeId} に cloudId が指定されていません。",
|
||||
"xpack.infra.kibanaMetrics.invalidInfraMetricErrorMessage": "{id} は有効な InfraMetric ではありません",
|
||||
|
@ -16484,16 +16483,26 @@
|
|||
"xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "セットアップの手順を表示",
|
||||
"xpack.infra.homePage.settingsTabTitle": "設定",
|
||||
"xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "インフラストラクチャデータを検索…(例:host.name:host-1)",
|
||||
"xpack.infra.hostsPage.experimentalBadgeDescription": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。",
|
||||
"xpack.infra.hostsPage.experimentalBadgeLabel": "テクニカルプレビュー",
|
||||
"xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "メモリー合計(平均)",
|
||||
"xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "メモリー使用状況(平均)",
|
||||
"xpack.infra.hostsTable.averageRxColumnHeader": "RX(平均)",
|
||||
"xpack.infra.hostsTable.averageTxColumnHeader": "TX(平均)",
|
||||
"xpack.infra.hostsTable.diskLatencyColumnHeader": "ディスクレイテンシ(平均)",
|
||||
"xpack.infra.hostsTable.nameColumnHeader": "名前",
|
||||
"xpack.infra.hostsTable.numberOfCpusColumnHeader": "CPU数",
|
||||
"xpack.infra.hostsTable.operatingSystemColumnHeader": "オペレーティングシステム",
|
||||
"xpack.infra.hostsViewPage.experimentalBadgeDescription": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。",
|
||||
"xpack.infra.hostsViewPage.experimentalBadgeLabel": "テクニカルプレビュー",
|
||||
"xpack.infra.hostsViewPage.metricTrend.cpu.title": "CPU使用状況",
|
||||
"xpack.infra.hostsViewPage.metricTrend.memory.title": "メモリー使用状況",
|
||||
"xpack.infra.hostsViewPage.metricTrend.rx.title": "受信(RX)",
|
||||
"xpack.infra.hostsViewPage.metricTrend.tx.title": "送信(TX)",
|
||||
"xpack.infra.hostsViewPage.table.averageMemoryTotalColumnHeader": "メモリー合計(平均)",
|
||||
"xpack.infra.hostsViewPage.table.averageMemoryUsageColumnHeader": "メモリー使用状況(平均)",
|
||||
"xpack.infra.hostsViewPage.table.averageRxColumnHeader": "RX(平均)",
|
||||
"xpack.infra.hostsViewPage.table.averageTxColumnHeader": "TX(平均)",
|
||||
"xpack.infra.hostsViewPage.table.diskLatencyColumnHeader": "ディスクレイテンシ(平均)",
|
||||
"xpack.infra.hostsViewPage.table.nameColumnHeader": "名前",
|
||||
"xpack.infra.hostsViewPage.table.numberOfCpusColumnHeader": "CPU数",
|
||||
"xpack.infra.hostsViewPage.table.operatingSystemColumnHeader": "オペレーティングシステム",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.actions.openInLines": "Lensで開く",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.title": "メトリック",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.cpu": "CPU使用状況",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.memory": "メモリー使用状況",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.rx": "受信(RX)",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "送信(TX)",
|
||||
"xpack.infra.infra.nodeDetails.apmTabLabel": "APM",
|
||||
"xpack.infra.infra.nodeDetails.createAlertLink": "インベントリルールの作成",
|
||||
"xpack.infra.infra.nodeDetails.openAsPage": "ページとして開く",
|
||||
|
@ -36550,4 +36559,4 @@
|
|||
"xpack.painlessLab.title": "Painless Lab",
|
||||
"xpack.painlessLab.walkthroughButtonLabel": "実地検証"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16295,7 +16295,6 @@
|
|||
"xpack.infra.deprecations.tiebreakerAdjustIndexing": "调整索引以将“{field}”用作决胜属性。",
|
||||
"xpack.infra.deprecations.timestampAdjustIndexing": "调整索引以将“{field}”用作时间戳。",
|
||||
"xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "选定时间过去 {duration}的数据",
|
||||
"xpack.infra.hostsTable.errorOnCreateOrLoadDataview": "尝试加载或创建以下数据视图时出错:{metricAlias}",
|
||||
"xpack.infra.inventoryTimeline.header": "平均值 {metricLabel}",
|
||||
"xpack.infra.kibanaMetrics.cloudIdMissingErrorMessage": "{metricId} 的模型需要云 ID,但没有为 {nodeId} 提供。",
|
||||
"xpack.infra.kibanaMetrics.invalidInfraMetricErrorMessage": "{id} 不是有效的 InfraMetric",
|
||||
|
@ -16503,16 +16502,27 @@
|
|||
"xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "查看设置说明",
|
||||
"xpack.infra.homePage.settingsTabTitle": "设置",
|
||||
"xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "搜索基础设施数据……(例如 host.name:host-1)",
|
||||
"xpack.infra.hostsPage.experimentalBadgeDescription": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。",
|
||||
"xpack.infra.hostsPage.experimentalBadgeLabel": "技术预览",
|
||||
"xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "内存合计(平均值)",
|
||||
"xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "内存使用率(平均值)",
|
||||
"xpack.infra.hostsTable.averageRxColumnHeader": "RX(平均值)",
|
||||
"xpack.infra.hostsTable.averageTxColumnHeader": "TX(平均值)",
|
||||
"xpack.infra.hostsTable.diskLatencyColumnHeader": "磁盘延迟(平均值)",
|
||||
"xpack.infra.hostsTable.nameColumnHeader": "名称",
|
||||
"xpack.infra.hostsTable.numberOfCpusColumnHeader": "# 个 CPU",
|
||||
"xpack.infra.hostsTable.operatingSystemColumnHeader": "操作系统",
|
||||
"xpack.infra.hostsViewPage.errorOnCreateOrLoadDataview": "尝试加载或创建以下数据视图时出错:{metricAlias}",
|
||||
"xpack.infra.hostsViewPage.experimentalBadgeDescription": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。",
|
||||
"xpack.infra.hostsViewPage.experimentalBadgeLabel": "技术预览",
|
||||
"xpack.infra.hostsViewPage.metricTrend.cpu.title": "CPU 使用率",
|
||||
"xpack.infra.hostsViewPage.metricTrend.memory.title": "内存利用率",
|
||||
"xpack.infra.hostsViewPage.metricTrend.rx.title": "入站 (RX)",
|
||||
"xpack.infra.hostsViewPage.metricTrend.tx.title": "出站 (TX)",
|
||||
"xpack.infra.hostsViewPage.table.averageMemoryTotalColumnHeader": "内存合计(平均值)",
|
||||
"xpack.infra.hostsViewPage.table.averageMemoryUsageColumnHeader": "内存使用率(平均值)",
|
||||
"xpack.infra.hostsViewPage.table.averageRxColumnHeader": "RX(平均值)",
|
||||
"xpack.infra.hostsViewPage.table.averageTxColumnHeader": "TX(平均值)",
|
||||
"xpack.infra.hostsViewPage.table.diskLatencyColumnHeader": "磁盘延迟(平均值)",
|
||||
"xpack.infra.hostsViewPage.table.nameColumnHeader": "名称",
|
||||
"xpack.infra.hostsViewPage.table.numberOfCpusColumnHeader": "# 个 CPU",
|
||||
"xpack.infra.hostsViewPage.table.operatingSystemColumnHeader": "操作系统",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.actions.openInLines": "在 Lens 中打开",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.title": "指标",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.cpu": "CPU 使用率",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.memory": "内存利用率",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.rx": "入站 (RX)",
|
||||
"xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "出站 (TX)",
|
||||
"xpack.infra.infra.nodeDetails.apmTabLabel": "APM",
|
||||
"xpack.infra.infra.nodeDetails.createAlertLink": "创建库存规则",
|
||||
"xpack.infra.infra.nodeDetails.openAsPage": "以页面形式打开",
|
||||
|
@ -36586,4 +36596,4 @@
|
|||
"xpack.painlessLab.title": "Painless 实验室",
|
||||
"xpack.painlessLab.walkthroughButtonLabel": "指导"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -49,7 +49,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
});
|
||||
|
||||
it('should load 5 metrics trend tiles', async () => {
|
||||
const hosts = await pageObjects.infraHostsView.getMetricsTrendTilesCount();
|
||||
const hosts = await pageObjects.infraHostsView.getAllMetricsTrendTiles();
|
||||
expect(hosts.length).to.equal(5);
|
||||
});
|
||||
|
||||
|
@ -65,6 +65,17 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
expect(tileValue).to.eql(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lens charts', () => {
|
||||
it('should load 7 lens metric charts', async () => {
|
||||
const metricCharts = await pageObjects.infraHostsView.getAllMetricsCharts();
|
||||
expect(metricCharts.length).to.equal(7);
|
||||
});
|
||||
|
||||
it('should have an option to open the chart in lens', async () => {
|
||||
await pageObjects.infraHostsView.getOpenInLensOption();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -20,7 +20,11 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) {
|
|||
return testSubjects.find('hostsView-metricsTrend');
|
||||
},
|
||||
|
||||
async getMetricsTrendTilesCount() {
|
||||
async getChartsContainer() {
|
||||
return testSubjects.find('hostsView-metricChart');
|
||||
},
|
||||
|
||||
async getAllMetricsTrendTiles() {
|
||||
const container = await this.getMetricsTrendContainer();
|
||||
return container.findAllByCssSelector('[data-test-subj*="hostsView-metricsTrend-"]');
|
||||
},
|
||||
|
@ -31,5 +35,20 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) {
|
|||
const div = await element.findByClassName('echMetricText__value');
|
||||
return await div.getAttribute('title');
|
||||
},
|
||||
|
||||
async getAllMetricsCharts() {
|
||||
const container = await this.getChartsContainer();
|
||||
return container.findAllByCssSelector('[data-test-subj*="hostsView-metricChart-"]');
|
||||
},
|
||||
|
||||
async getOpenInLensOption() {
|
||||
const metricCharts = await this.getAllMetricsCharts();
|
||||
const chart = metricCharts[0];
|
||||
await chart.moveMouseTo();
|
||||
const button = await testSubjects.findDescendant('embeddablePanelToggleMenuIcon', chart);
|
||||
await button.click();
|
||||
await testSubjects.existOrFail('embeddablePanelContextMenuOpen');
|
||||
return testSubjects.existOrFail('embeddablePanelAction-openInLens');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue