diff --git a/package.json b/package.json index 4814089..2a3922b 100644 --- a/package.json +++ b/package.json @@ -78,5 +78,5 @@ "react-dom": "17.0.2", "tslib": "2.5.3" }, - "packageManager": "yarn@" + "packageManager": "yarn@1.22.21" } diff --git a/src/RequestSpec.ts b/src/RequestSpec.ts index fea63d3..82a8b2a 100644 --- a/src/RequestSpec.ts +++ b/src/RequestSpec.ts @@ -31,6 +31,8 @@ export interface RequestSpec { service_in_group: NegatableOption | undefined; graph: string | undefined; + + label: string | undefined; } // subset of RequestSpec used with the Filters Component diff --git a/src/backend/rest.ts b/src/backend/rest.ts index 282a3c4..af03031 100644 --- a/src/backend/rest.ts +++ b/src/backend/rest.ts @@ -8,6 +8,7 @@ import { FieldType, MetricFindValue, MutableDataFrame, + ScopedVars, TimeRange, dateTime, } from '@grafana/data'; @@ -16,7 +17,7 @@ import { Aggregation, GraphType, MetricFindQuery } from 'RequestSpec'; import * as process from 'process'; import { CmkQuery } from '../types'; -import { createCmkContext, replaceVariables, toLiveStatusQuery, updateQuery } from '../utils'; +import { createCmkContext, replaceVariables, toLiveStatusQuery, updateMetricTitles, updateQuery } from '../utils'; import { Backend, DatasourceOptions } from './types'; import { validateRequestSpec } from './validate'; @@ -26,18 +27,20 @@ type RestApiError = { title: string; }; +export type MetricResponse = { + color: string; + data_points: number[]; + line_type: string; + title: string; +}; + type RestApiGraphResponse = { time_range: { start: string; end: string; }; step: number; - metrics: Array<{ - color: string; - data_points: number[]; - line_type: string; - title: string; - }>; + metrics: MetricResponse[]; }; type CommonRequest = { @@ -155,7 +158,7 @@ export default class RestApiBackend implements Backend { const promises = request.targets .filter((target) => !target.hide) .map((target) => { - return this.getSingleGraph(request.range, target); + return this.getSingleGraph(request.range, target, request.scopedVars); }); return await Promise.all(promises).then((data) => ({ data })); } @@ -250,7 +253,7 @@ export default class RestApiBackend implements Backend { return result; } - async getSingleGraph(range: TimeRange, query: CmkQuery): Promise { + async getSingleGraph(range: TimeRange, query: CmkQuery, scopedVars: ScopedVars = {}): Promise { // it's not about a single graph line, but a single chart. grafana supports // to query multiple graphs in one request, but we have to unwind this, as // our api only supports a single chart/query per api call. @@ -322,6 +325,8 @@ export default class RestApiBackend implements Backend { const { time_range, step, metrics } = response.data; + updateMetricTitles(metrics, query, scopedVars); + const timeValues = []; let currentTime: DateTime = dateTime(time_range.start); const endTime: DateTime = dateTime(time_range.end); diff --git a/src/backend/web.ts b/src/backend/web.ts index bf6541a..abecd68 100644 --- a/src/backend/web.ts +++ b/src/backend/web.ts @@ -1,10 +1,17 @@ -import { DataQueryRequest, DataQueryResponse, FieldType, MetricFindValue, MutableDataFrame } from '@grafana/data'; +import { + DataQueryRequest, + DataQueryResponse, + FieldType, + MetricFindValue, + MutableDataFrame, + ScopedVars, +} from '@grafana/data'; import { BackendSrvRequest, FetchError, FetchResponse, getBackendSrv } from '@grafana/runtime'; import { MetricFindQuery } from 'RequestSpec'; import { defaults, get, isUndefined, zip } from 'lodash'; import { CmkQuery, defaultQuery } from '../types'; -import { updateQuery } from '../utils'; +import { updateMetricTitles, updateQuery } from '../utils'; import { WebAPiGetGraphResult, WebApiResponse, @@ -89,7 +96,7 @@ export default class WebApiBackend implements Backend { .map((target) => { // TODO: check if the defaults call is still necessary. const query = defaults(target, defaultQuery); - return this.getGraphQuery([from, to], query); + return this.getGraphQuery([from, to], query, options.scopedVars); }); return Promise.all(promises).then((data) => ({ data })); } @@ -145,7 +152,11 @@ export default class WebApiBackend implements Backend { } } - async getGraphQuery(range: number[], query: CmkQuery): Promise> { + async getGraphQuery( + range: number[], + query: CmkQuery, + scopedVars: ScopedVars = {} + ): Promise> { updateQuery(query); const graph = get(query, 'requestSpec.graph'); if (isUndefined(graph) || graph === '') { @@ -173,8 +184,11 @@ export default class WebApiBackend implements Backend { if (response.result_code !== 0) { throw new Error(`${response.result}`); } + const { start_time, step, curves } = response.result; + updateMetricTitles(curves, query, scopedVars); + const frame = new MutableDataFrame({ refId: query.refId, fields: [ diff --git a/src/types.ts b/src/types.ts index 68fb4fc..077b320 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,3 +78,12 @@ export interface SecureJsonData { export interface ResponseDataAutocomplete { choices: Array<[string, string]>; } + +export enum label { + ORIGINAL = '$label', + SITE = '$filter_site', + HOSTNAME = '$filter_host_name', + HOST_IN_GROUP = '$filter_host_in_group', + SERVICE = '$filter_service', + SERVICE_IN_GROUP = '$filter_service_in_group', +} diff --git a/src/ui/QueryEditor.tsx b/src/ui/QueryEditor.tsx index 80b7693..8d1479d 100644 --- a/src/ui/QueryEditor.tsx +++ b/src/ui/QueryEditor.tsx @@ -1,12 +1,12 @@ import { QueryEditorProps, SelectableValue } from '@grafana/data'; -import { InlineFieldRow, VerticalGroup } from '@grafana/ui'; +import { Button, Card, Icon, InlineFieldRow, Toggletip, VerticalGroup } from '@grafana/ui'; import React from 'react'; import { DataSource } from '../DataSource'; import { Aggregation, GraphType, RequestSpec } from '../RequestSpec'; -import { CmkQuery, DataSourceOptions } from '../types'; +import { CmkQuery, DataSourceOptions, label } from '../types'; import { aggregationToPresentation, updateQuery } from '../utils'; -import { CheckMkSelect } from './components'; +import { CheckMkSelect, GenericField } from './components'; import { Filters } from './filters'; import { labelForRequestSpecKey } from './utils'; @@ -19,6 +19,8 @@ export const QueryEditor = (props: Props): JSX.Element => { const [qAggregation, setQAggregation] = React.useState(rs.aggregation || 'off'); const [qGraphType, setQGraphType] = React.useState(rs.graph_type || 'predefined_graph'); const [qGraph, setQGraph] = React.useState(rs.graph); + const [qLabel, setQLabel] = React.useState(rs.label); + const filters: Partial = { // make sure to only include keys filters should change, otherwise they could // overwrite other fields! @@ -43,6 +45,7 @@ export const QueryEditor = (props: Props): JSX.Element => { graph_type: qGraphType, graph: qGraph, aggregation: qAggregation, + label: qLabel, }; // TODO: not sure if this is a dirty hack or a great solution: @@ -109,6 +112,56 @@ export const QueryEditor = (props: Props): JSX.Element => { /> ); + const LabelsTooltip = ( + + + These variables are supported if provided as filters: +
    + {[ + [label.SITE, 'Site name'], + [label.HOSTNAME, 'Host name'], + [label.HOST_IN_GROUP, 'Group containing the host'], + [label.SERVICE, 'Service name'], + [label.SERVICE_IN_GROUP, 'Group containing the service'], + ].map((lbl) => ( +
  • + {lbl[0]}: {lbl[1]} +
  • + ))} +
+
+ Also, the original label is available as +
    +
  • + {label.ORIGINAL} +
  • +
+
+ + } + closeButton={false} + placement="right-end" + > + +
+ ); + + const labelField = ( + + ); + if (editionMode === 'RAW') { return ( @@ -122,6 +175,7 @@ export const QueryEditor = (props: Props): JSX.Element => { /> {graphTypeSelect} {graphSelect} + {labelField} ); } else { @@ -139,6 +193,7 @@ export const QueryEditor = (props: Props): JSX.Element => { /> {graphTypeSelect} {graphSelect} + {labelField} ); } diff --git a/src/ui/components.tsx b/src/ui/components.tsx index d987482..6637e40 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -288,6 +288,67 @@ export const Filter = (props: FilterPr ); }; +interface GenericFieldProps extends CommonProps { + requestSpecKey: Key; + children?: React.ReactNode; + width?: number; + tooltip?: string; + dataTestId?: string; + placeholder?: string; + prefix?: React.ReactNode; + suffix?: React.ReactNode; +} +export const GenericField = (props: GenericFieldProps) => { + const { + label, + width = 32, + onChange, + value, + requestSpecKey, + children = null, + tooltip, + placeholder = 'none', + prefix, + suffix, + } = props; + const { dataTestId = `${requestSpecKey}-filter-input` } = props; + + const [textValue, setTextValue] = React.useState(value !== undefined ? value : ''); + + const debouncedOnChange = React.useMemo( + () => + debounce((newValue) => { + onChange(newValue); + }, 1000), + [onChange] + ); + + const onValueChange = (event: React.ChangeEvent) => { + setTextValue(event.target.value); // update text input + debouncedOnChange(event.target.value); // only the last debounce call comes through + }; + + return ( + + + <> + + {children} + + + + ); +}; + const SingleTag = (props: { index: number; onChange: (newValue: TagValue) => void; diff --git a/src/ui/utils.ts b/src/ui/utils.ts index f8e31fe..e3f54f7 100644 --- a/src/ui/utils.ts +++ b/src/ui/utils.ts @@ -14,6 +14,7 @@ export const labelForRequestSpecKey = (key: keyof RequestSpec, rq: Partial str[0].toUpperCase() + str.slice(1).toLowerCase(); @@ -276,3 +277,25 @@ export function toLiveStatusQuery(filter: Partial, table: 'h query: query, }; } + +type GrapResponse = WebApiCurve | MetricResponse; + +export function updateMetricTitles(metrics: GrapResponse[], query: CmkQuery, scopedVars: ScopedVars = {}) { + const titleTemplate = query.requestSpec?.label || label.ORIGINAL; + if (titleTemplate !== label.ORIGINAL) { + scopedVars = { + ...scopedVars, + [label.SITE.substring(1)]: { value: query.requestSpec?.site || '' }, + [label.HOSTNAME.substring(1)]: { value: query.requestSpec?.host_name || '' }, + [label.HOST_IN_GROUP.substring(1)]: { value: query.requestSpec?.host_in_group?.value || '' }, + [label.SERVICE.substring(1)]: { value: query.requestSpec?.service || '' }, + [label.SERVICE_IN_GROUP.substring(1)]: { value: query.requestSpec?.service_in_group?.value || '' }, + [label.ORIGINAL.substring(1)]: { value: '' }, + }; + + metrics.forEach((metric) => { + scopedVars[label.ORIGINAL.substring(1)] = { value: metric.title }; + metric.title = getTemplateSrv().replace(titleTemplate, scopedVars); + }); + } +} diff --git a/src/webapi.ts b/src/webapi.ts index 32946fb..735d870 100644 --- a/src/webapi.ts +++ b/src/webapi.ts @@ -2,17 +2,18 @@ import { GraphType, NegatableOption, RequestSpec, TagValue } from './RequestSpec import { Context, Edition, Params } from './types'; import { aggregationToPresentation, createCmkContext, presentationToAggregation } from './utils'; +export interface WebApiCurve { + title: string; + rrddata: Array<{ + i: number; + d: Record; + }>; +} export interface WebAPiGetGraphResult { start_time: number; end_time: number; step: number; - curves: Array<{ - title: string; - rrddata: Array<{ - i: number; - d: Record; - }>; - }>; + curves: WebApiCurve[]; } export interface WebApiResponse { diff --git a/tests/cypress/e2e/spec.cy.ts b/tests/cypress/e2e/spec.cy.ts index ffe8857..8c7d976 100644 --- a/tests/cypress/e2e/spec.cy.ts +++ b/tests/cypress/e2e/spec.cy.ts @@ -1,6 +1,7 @@ import '../support/api_commands'; import CheckmkSelectors from '../support/checkmk_selectors'; import '../support/commands'; +import { label } from '../types'; describe('e2e tests', () => { const cmkUser = 'cmkuser'; @@ -21,6 +22,8 @@ describe('e2e tests', () => { const inputServiceId = 'input_Service'; const inputSiteId = 'input_Site'; const inputHostLabelId = CheckmkSelectors.AddDashboard.hostLabelFieldId; + const inputCustomLabelSelector = 'input[data-test-id="custom-label-field"]'; + const refreshQueryButtonSelector = 'button[data-test-id="data-testid RefreshPicker run button"]'; const inputHostRegexDataTestId = 'host_name_regex-filter-input'; const inputServiceRegexDataTestId = 'service_regex-filter-input'; @@ -280,6 +283,36 @@ describe('e2e tests', () => { cy.assertHoverSelectorsOff(1); cy.assertHoverSelectorsOn(1); }); + it('Custom labels', {}, () => { + cy.selectDataSource(CmkCEE); + + cy.contains('Checkmk ' + CmkCEE).should('be.visible'); // Assert Cmk CEE datasource is used + + cy.inputLocatorById(inputFilterId).type('Hostname').type('{enter}'); // Filter -> 'Host name' + cy.inputLocatorById(inputHostId).type(hostName0).type('{enter}'); // Hostname -> hostName0 + cy.contains(hostName0).should('exist'); + + cy.get(`#${inputGraphId}`).type('time usage by phase').type('{enter}'); // Predefined graph -> 'Time usage by phase' (one entry) + + cy.contains('Time usage by phase').should('exist'); + + cy.assertLegendElement(`CPU time in user space`); + + //Label $label + cy.get(inputCustomLabelSelector).clear().type(label.ORIGINAL).type('{enter}'); + cy.refreshGraph(); + cy.assertLegendElement(`CPU time in user space`); + + //Label $label + constant + cy.get(inputCustomLabelSelector).clear().type(`${label.ORIGINAL} - LMP`).type('{enter}'); + cy.refreshGraph(); + cy.assertLegendElement(`CPU time in user space - LMP`); + + //Label $host_name + $label + cy.get(inputCustomLabelSelector).clear().type(`${label.ORIGINAL} - ${label.HOSTNAME}`).type('{enter}'); + cy.refreshGraph(); + cy.assertLegendElement(`CPU time in user space - ${hostName0}`); + }); }); describe('CRE tests', () => { it('time-usage panel by service (single host)', {}, () => { diff --git a/tests/cypress/support/commands.ts b/tests/cypress/support/commands.ts index e60867b..a212e7b 100644 --- a/tests/cypress/support/commands.ts +++ b/tests/cypress/support/commands.ts @@ -136,6 +136,14 @@ Cypress.Commands.add('inputLocatorByDataTestId', (dataTestId: string) => { return cy.get(`input[data-test-id="${dataTestId}"]`); }); +Cypress.Commands.add('getById', (id: string) => { + return cy.get(`#${id}`); +}); + +Cypress.Commands.add('refreshGraph', () => { + cy.get('button[data-testid="data-testid RefreshPicker run button"]').click(); +}); + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { @@ -154,6 +162,8 @@ declare global { inputLocatorById(id: string): Chainable; inputLocatorByDataTestId(dataTestId: string): Chainable; selectDataSource(edition: string): Chainable; + getById(id: string): Chainable; + refreshGraph(): Chainable; } } } diff --git a/tests/cypress/types.ts b/tests/cypress/types.ts new file mode 100644 index 0000000..df51147 --- /dev/null +++ b/tests/cypress/types.ts @@ -0,0 +1 @@ +export * from '../../src/types'; diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index d4c5278..ce522c8 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -38,3 +38,5 @@ services: - ./cypress:/cypress - ./cypress.config.js:/cypress.config.js - ./cypress.env.json:/cypress.env.json + - ../src/types.ts:/cypress/types.ts:ro + - ../src/RequestSpec.ts:/cypress/RequestSpec.ts:ro diff --git a/tests/package.json b/tests/package.json index 13777ab..f9208ce 100644 --- a/tests/package.json +++ b/tests/package.json @@ -9,6 +9,6 @@ "author": "", "license": "GPL-2.0-only", "devDependencies": { - "cypress": "13.3.3" + "cypress": "13.5.1" } } diff --git a/tests/yarn.lock b/tests/yarn.lock index a242443..7f3f016 100644 --- a/tests/yarn.lock +++ b/tests/yarn.lock @@ -316,10 +316,10 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -cypress@13.3.3: - version "13.3.3" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.3.3.tgz#353e69b6543aee8aee4e91fa39bcecbab26ab564" - integrity sha512-mbdkojHhKB1xbrj7CrKWHi22uFx9P9vQFiR0sYDZZoK99OMp9/ZYN55TO5pjbXmV7xvCJ4JwBoADXjOJK8aCJw== +cypress@13.5.1: + version "13.5.1" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.5.1.tgz#8b19bf0b9f31ea43f78980b2479bd3f25197d5cc" + integrity sha512-yqLViT0D/lPI8Kkm7ciF/x/DCK/H/DnogdGyiTnQgX4OVR2aM30PtK+kvklTOD1u3TuItiD9wUQAF8EYWtyZug== dependencies: "@cypress/request" "^3.0.0" "@cypress/xvfb" "^1.2.4"