mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scenes: Add alerts from panel edit (#81588)
* Display alerts in scenes dashboard * sort of working adding alerts * move alert button to own component * First fixes * Generate link/url on click * some cleanup * making sure all links from scene go back to scene dashboard/panel; add rule button when there are rules; styling * remove unused import * add &scenes to url for alert instance annotations * Add tests from old alert tab * Revert addition of &scenes to dashboard urls * Refactor to simplify NewAlertRuleButton interface * update test * Use the raw range to calculate the relative range --------- Co-authored-by: Torkel Ödegaard <torkel@grafana.com> Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
@@ -13,9 +13,15 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getDataSourceSrv } from '@grafana/runtime';
|
import { getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
||||||
|
import { sceneGraph, VizPanel } from '@grafana/scenes';
|
||||||
import { DataSourceJsonData } from '@grafana/schema';
|
import { DataSourceJsonData } from '@grafana/schema';
|
||||||
import { getNextRefIdChar } from 'app/core/utils/query';
|
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||||
|
import {
|
||||||
|
getDashboardSceneFor,
|
||||||
|
getPanelIdForVizPanel,
|
||||||
|
getQueryRunnerFor,
|
||||||
|
} from 'app/features/dashboard-scene/utils/utils';
|
||||||
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
||||||
import { LokiQuery } from 'app/plugins/datasource/loki/types';
|
import { LokiQuery } from 'app/plugins/datasource/loki/types';
|
||||||
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
|
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
|
||||||
@@ -482,7 +488,7 @@ const dataQueriesToGrafanaQueries = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const interpolatedTarget = datasource.interpolateVariablesInQueries
|
const interpolatedTarget = datasource.interpolateVariablesInQueries
|
||||||
? await datasource.interpolateVariablesInQueries([target], queryVariables)[0]
|
? datasource.interpolateVariablesInQueries([target], queryVariables)[0]
|
||||||
: target;
|
: target;
|
||||||
|
|
||||||
// expressions
|
// expressions
|
||||||
@@ -579,6 +585,77 @@ export const panelToRuleFormValues = async (
|
|||||||
return formValues;
|
return formValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise<Partial<RuleFormValues> | undefined> => {
|
||||||
|
if (!vizPanel.state.key) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeRange = sceneGraph.getTimeRange(vizPanel);
|
||||||
|
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||||
|
if (!queryRunner) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const { queries, datasource, maxDataPoints, minInterval } = queryRunner.state;
|
||||||
|
|
||||||
|
const dashboard = getDashboardSceneFor(vizPanel);
|
||||||
|
if (!dashboard || !dashboard.state.uid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grafanaQueries = await dataQueriesToGrafanaQueries(
|
||||||
|
queries,
|
||||||
|
rangeUtil.timeRangeToRelative(rangeUtil.convertRawToRange(timeRange.state.value.raw)),
|
||||||
|
{ __sceneObject: { value: vizPanel } },
|
||||||
|
datasource,
|
||||||
|
maxDataPoints,
|
||||||
|
minInterval
|
||||||
|
);
|
||||||
|
|
||||||
|
// if no alerting capable queries are found, can't create a rule
|
||||||
|
if (!grafanaQueries.length || !grafanaQueries.find((query) => query.datasourceUid !== ExpressionDatasourceUID)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grafanaQueries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
||||||
|
const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefIdChar(grafanaQueries), '-');
|
||||||
|
grafanaQueries.push(reduceExpression);
|
||||||
|
|
||||||
|
const [_reduceExpression, thresholdExpression] = getDefaultExpressions(
|
||||||
|
reduceExpression.refId,
|
||||||
|
getNextRefIdChar(grafanaQueries)
|
||||||
|
);
|
||||||
|
grafanaQueries.push(thresholdExpression);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { folderTitle, folderUid } = dashboard.state.meta;
|
||||||
|
|
||||||
|
const formValues = {
|
||||||
|
type: RuleFormType.grafana,
|
||||||
|
folder:
|
||||||
|
folderUid && folderTitle
|
||||||
|
? {
|
||||||
|
uid: folderUid,
|
||||||
|
title: folderTitle,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
queries: grafanaQueries,
|
||||||
|
name: vizPanel.state.title,
|
||||||
|
condition: grafanaQueries[grafanaQueries.length - 1].refId,
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
key: Annotation.dashboardUID,
|
||||||
|
value: dashboard.state.uid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: Annotation.panelID,
|
||||||
|
|
||||||
|
value: String(getPanelIdForVizPanel(vizPanel)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return formValues;
|
||||||
|
};
|
||||||
|
|
||||||
export function getIntervals(range: TimeRange, lowLimit?: string, resolution?: number): IntervalValues {
|
export function getIntervals(range: TimeRange, lowLimit?: string, resolution?: number): IntervalValues {
|
||||||
if (!resolution) {
|
if (!resolution) {
|
||||||
if (lowLimit && rangeUtil.intervalToMs(lowLimit) > 1000) {
|
if (lowLimit && rangeUtil.intervalToMs(lowLimit) > 1000) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
|
import { urlUtil } from '@grafana/data';
|
||||||
|
import { locationService, logInfo } from '@grafana/runtime';
|
||||||
|
import { VizPanel } from '@grafana/scenes';
|
||||||
|
import { Alert, Button } from '@grafana/ui';
|
||||||
|
import { LogMessages } from 'app/features/alerting/unified/Analytics';
|
||||||
|
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
|
||||||
|
|
||||||
|
interface ScenesNewRuleFromPanelButtonProps {
|
||||||
|
panel: VizPanel;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRuleFromPanelButtonProps) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const { loading, value: formValues } = useAsync(() => scenesPanelToRuleFormValues(panel), [panel]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Button disabled={true}>New alert rule</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formValues) {
|
||||||
|
return (
|
||||||
|
<Alert severity="info" title="No alerting capable query found">
|
||||||
|
Cannot create alerts from this panel because no query to an alerting capable datasource is found.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
logInfo(LogMessages.alertRuleFromPanel);
|
||||||
|
|
||||||
|
const updateToDateFormValues = await scenesPanelToRuleFormValues(panel);
|
||||||
|
|
||||||
|
const ruleFormUrl = urlUtil.renderUrl('/alerting/new', {
|
||||||
|
defaults: JSON.stringify(updateToDateFormValues),
|
||||||
|
returnTo: location.pathname + location.search,
|
||||||
|
});
|
||||||
|
|
||||||
|
locationService.push(ruleFormUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button icon="bell" onClick={onClick} className={className} data-testid="create-alert-rule-button">
|
||||||
|
New alert rule
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { act, render } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
import { byTestId } from 'testing-library-selector';
|
||||||
|
|
||||||
|
import { DataSourceApi } from '@grafana/data';
|
||||||
|
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||||
|
import { fetchRules } from 'app/features/alerting/unified/api/prometheus';
|
||||||
|
import { fetchRulerRules } from 'app/features/alerting/unified/api/ruler';
|
||||||
|
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
|
||||||
|
import {
|
||||||
|
MockDataSourceSrv,
|
||||||
|
grantUserPermissions,
|
||||||
|
mockDataSource,
|
||||||
|
mockPromAlertingRule,
|
||||||
|
mockPromRuleGroup,
|
||||||
|
mockPromRuleNamespace,
|
||||||
|
mockRulerGrafanaRule,
|
||||||
|
} from 'app/features/alerting/unified/mocks';
|
||||||
|
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
|
||||||
|
import * as config from 'app/features/alerting/unified/utils/config';
|
||||||
|
import { Annotation } from 'app/features/alerting/unified/utils/constants';
|
||||||
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||||
|
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||||
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource';
|
||||||
|
import { PromOptions } from 'app/plugins/datasource/prometheus/types';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene';
|
||||||
|
import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils';
|
||||||
|
import * as utils from '../../utils/utils';
|
||||||
|
import { VizPanelManager } from '../VizPanelManager';
|
||||||
|
|
||||||
|
import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These tests has been copied from public/app/features/alerting/unified/PanelAlertTabContent.test.tsx and been slightly modified to make sure the scenes alert edit tab is as close to the old alert edit tab as possible
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock('app/features/alerting/unified/api/prometheus');
|
||||||
|
jest.mock('app/features/alerting/unified/api/ruler');
|
||||||
|
|
||||||
|
jest.spyOn(config, 'getAllDataSources');
|
||||||
|
jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false);
|
||||||
|
|
||||||
|
const dataSources = {
|
||||||
|
prometheus: mockDataSource<PromOptions>({
|
||||||
|
name: 'Prometheus',
|
||||||
|
type: DataSourceType.Prometheus,
|
||||||
|
isDefault: false,
|
||||||
|
}),
|
||||||
|
default: mockDataSource<PromOptions>({
|
||||||
|
name: 'Default',
|
||||||
|
type: DataSourceType.Prometheus,
|
||||||
|
isDefault: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
dataSources.prometheus.meta.alerting = true;
|
||||||
|
dataSources.default.meta.alerting = true;
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
getAllDataSources: jest.mocked(config.getAllDataSources),
|
||||||
|
api: {
|
||||||
|
fetchRules: jest.mocked(fetchRules),
|
||||||
|
fetchRulerRules: jest.mocked(fetchRulerRules),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAlertTabContent = (model: PanelDataAlertingTab, initialStore?: ReturnType<typeof configureStore>) => {
|
||||||
|
render(
|
||||||
|
<TestProvider store={initialStore}>
|
||||||
|
<PanelDataAlertingTabRendered model={model}></PanelDataAlertingTabRendered>
|
||||||
|
</TestProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules = [
|
||||||
|
mockPromRuleNamespace({
|
||||||
|
name: 'default',
|
||||||
|
groups: [
|
||||||
|
mockPromRuleGroup({
|
||||||
|
name: 'mygroup',
|
||||||
|
rules: [
|
||||||
|
mockPromAlertingRule({
|
||||||
|
name: 'dashboardrule1',
|
||||||
|
annotations: {
|
||||||
|
[Annotation.dashboardUID]: '12',
|
||||||
|
[Annotation.panelID]: '34',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
mockPromRuleGroup({
|
||||||
|
name: 'othergroup',
|
||||||
|
rules: [
|
||||||
|
mockPromAlertingRule({
|
||||||
|
name: 'dashboardrule2',
|
||||||
|
annotations: {
|
||||||
|
[Annotation.dashboardUID]: '121',
|
||||||
|
[Annotation.panelID]: '341',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rulerRules = {
|
||||||
|
default: [
|
||||||
|
{
|
||||||
|
name: 'mygroup',
|
||||||
|
rules: [
|
||||||
|
mockRulerGrafanaRule(
|
||||||
|
{
|
||||||
|
annotations: {
|
||||||
|
[Annotation.dashboardUID]: '12',
|
||||||
|
[Annotation.panelID]: '34',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'dashboardrule1',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'othergroup',
|
||||||
|
rules: [
|
||||||
|
mockRulerGrafanaRule(
|
||||||
|
{
|
||||||
|
annotations: {
|
||||||
|
[Annotation.dashboardUID]: '121',
|
||||||
|
[Annotation.panelID]: '341',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'dashboardrule2',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashboard = {
|
||||||
|
uid: '12',
|
||||||
|
time: {
|
||||||
|
from: 'now-6h',
|
||||||
|
to: 'now',
|
||||||
|
},
|
||||||
|
timepicker: { refresh_intervals: 5 },
|
||||||
|
meta: {
|
||||||
|
canSave: true,
|
||||||
|
folderId: 1,
|
||||||
|
folderTitle: 'super folder',
|
||||||
|
},
|
||||||
|
} as unknown as DashboardModel;
|
||||||
|
|
||||||
|
const panel = new PanelModel({
|
||||||
|
datasource: {
|
||||||
|
type: 'prometheus',
|
||||||
|
uid: dataSources.prometheus.uid,
|
||||||
|
},
|
||||||
|
title: 'mypanel',
|
||||||
|
id: 34,
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expr: 'sum(some_metric [$__interval])) by (app)',
|
||||||
|
refId: 'A',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ui = {
|
||||||
|
row: byTestId('row'),
|
||||||
|
createButton: byTestId<HTMLButtonElement>('create-alert-rule-button'),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PanelAlertTabContent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
grantUserPermissions([
|
||||||
|
AccessControlAction.AlertingRuleRead,
|
||||||
|
AccessControlAction.AlertingRuleUpdate,
|
||||||
|
AccessControlAction.AlertingRuleDelete,
|
||||||
|
AccessControlAction.AlertingRuleCreate,
|
||||||
|
AccessControlAction.AlertingRuleExternalRead,
|
||||||
|
AccessControlAction.AlertingRuleExternalWrite,
|
||||||
|
]);
|
||||||
|
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
|
||||||
|
const dsService = new MockDataSourceSrv(dataSources);
|
||||||
|
dsService.datasources[dataSources.prometheus.uid] = new PrometheusDatasource(
|
||||||
|
dataSources.prometheus
|
||||||
|
) as DataSourceApi;
|
||||||
|
dsService.datasources[dataSources.default.uid] = new PrometheusDatasource(dataSources.default) as DataSourceApi;
|
||||||
|
setDataSourceSrv(dsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Will take into account panel maxDataPoints', async () => {
|
||||||
|
dashboard.panels = [
|
||||||
|
new PanelModel({
|
||||||
|
...panel,
|
||||||
|
maxDataPoints: 100,
|
||||||
|
interval: '10s',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderAlertTab(dashboard);
|
||||||
|
|
||||||
|
const defaults = await clickNewButton();
|
||||||
|
|
||||||
|
expect(defaults.queries[0].model).toEqual({
|
||||||
|
expr: 'sum(some_metric [5m])) by (app)',
|
||||||
|
refId: 'A',
|
||||||
|
datasource: {
|
||||||
|
type: 'prometheus',
|
||||||
|
uid: 'mock-ds-2',
|
||||||
|
},
|
||||||
|
interval: '',
|
||||||
|
intervalMs: 300000,
|
||||||
|
maxDataPoints: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Will work with default datasource', async () => {
|
||||||
|
dashboard.panels = [
|
||||||
|
new PanelModel({
|
||||||
|
...panel,
|
||||||
|
datasource: undefined,
|
||||||
|
maxDataPoints: 100,
|
||||||
|
interval: '10s',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderAlertTab(dashboard);
|
||||||
|
const defaults = await clickNewButton();
|
||||||
|
|
||||||
|
expect(defaults.queries[0].model).toEqual({
|
||||||
|
expr: 'sum(some_metric [5m])) by (app)',
|
||||||
|
refId: 'A',
|
||||||
|
datasource: {
|
||||||
|
type: 'prometheus',
|
||||||
|
uid: 'mock-ds-3',
|
||||||
|
},
|
||||||
|
interval: '',
|
||||||
|
intervalMs: 300000,
|
||||||
|
maxDataPoints: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Will take into account datasource minInterval', async () => {
|
||||||
|
(getDatasourceSrv() as unknown as MockDataSourceSrv).datasources[dataSources.prometheus.uid].interval = '7m';
|
||||||
|
|
||||||
|
dashboard.panels = [
|
||||||
|
new PanelModel({
|
||||||
|
...panel,
|
||||||
|
maxDataPoints: 100,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderAlertTab(dashboard);
|
||||||
|
const defaults = await clickNewButton();
|
||||||
|
|
||||||
|
expect(defaults.queries[0].model).toEqual({
|
||||||
|
expr: 'sum(some_metric [7m])) by (app)',
|
||||||
|
refId: 'A',
|
||||||
|
datasource: {
|
||||||
|
type: 'prometheus',
|
||||||
|
uid: 'mock-ds-2',
|
||||||
|
},
|
||||||
|
interval: '',
|
||||||
|
intervalMs: 420000,
|
||||||
|
maxDataPoints: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Will render alerts belonging to panel and a button to create alert from panel queries', async () => {
|
||||||
|
mocks.api.fetchRules.mockResolvedValue(rules);
|
||||||
|
mocks.api.fetchRulerRules.mockResolvedValue(rulerRules);
|
||||||
|
|
||||||
|
dashboard.panels = [panel];
|
||||||
|
|
||||||
|
renderAlertTab(dashboard);
|
||||||
|
|
||||||
|
const rows = await ui.row.findAll();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toHaveTextContent(/dashboardrule1/);
|
||||||
|
expect(rows[0]).not.toHaveTextContent(/dashboardrule2/);
|
||||||
|
|
||||||
|
const defaults = await clickNewButton();
|
||||||
|
|
||||||
|
const defaultsWithDeterministicTime: Partial<RuleFormValues> = {
|
||||||
|
...defaults,
|
||||||
|
queries: defaults.queries.map((q: AlertQuery) => {
|
||||||
|
return {
|
||||||
|
...q,
|
||||||
|
// Fix computed time stamp to avoid assertion flakiness
|
||||||
|
...(q.relativeTimeRange ? { relativeTimeRange: { from: 21600, to: 0 } } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(defaultsWithDeterministicTime).toMatchSnapshot();
|
||||||
|
|
||||||
|
expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith(
|
||||||
|
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
|
||||||
|
{
|
||||||
|
dashboardUID: dashboard.uid,
|
||||||
|
panelId: panel.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(mocks.api.fetchRules).toHaveBeenCalledWith(
|
||||||
|
GRAFANA_RULES_SOURCE_NAME,
|
||||||
|
{
|
||||||
|
dashboardUID: dashboard.uid,
|
||||||
|
panelId: panel.id,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderAlertTab(dashboard: DashboardModel) {
|
||||||
|
const model = createModel(dashboard);
|
||||||
|
renderAlertTabContent(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickNewButton() {
|
||||||
|
const pushMock = jest.fn();
|
||||||
|
const oldPush = locationService.push;
|
||||||
|
locationService.push = pushMock;
|
||||||
|
const button = await ui.createButton.find();
|
||||||
|
await act(async () => {
|
||||||
|
await userEvent.click(button);
|
||||||
|
});
|
||||||
|
const match = pushMock.mock.lastCall[0].match(/alerting\/new\?defaults=(.*)&returnTo=/);
|
||||||
|
const defaults = JSON.parse(decodeURIComponent(match![1]));
|
||||||
|
locationService.push = oldPush;
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModel(dashboard: DashboardModel) {
|
||||||
|
const scene = createDashboardSceneFromDashboardModel(dashboard);
|
||||||
|
const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34));
|
||||||
|
const model = new PanelDataAlertingTab(new VizPanelManager(vizPanel!.clone()));
|
||||||
|
jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
|
import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
|
||||||
import { Alert, LoadingPlaceholder, Tab } from '@grafana/ui';
|
import { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable';
|
import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable';
|
||||||
import { usePanelCombinedRules } from 'app/features/alerting/unified/hooks/usePanelCombinedRules';
|
import { usePanelCombinedRules } from 'app/features/alerting/unified/hooks/usePanelCombinedRules';
|
||||||
|
import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
|
||||||
|
|
||||||
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils';
|
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils';
|
||||||
import { VizPanelManager } from '../VizPanelManager';
|
import { VizPanelManager } from '../VizPanelManager';
|
||||||
|
|
||||||
|
import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton';
|
||||||
import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types';
|
import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types';
|
||||||
|
|
||||||
export class PanelDataAlertingTab extends SceneObjectBase<PanelDataPaneTabState> implements PanelDataPaneTab {
|
export class PanelDataAlertingTab extends SceneObjectBase<PanelDataPaneTabState> implements PanelDataPaneTab {
|
||||||
@@ -22,31 +27,46 @@ export class PanelDataAlertingTab extends SceneObjectBase<PanelDataPaneTabState>
|
|||||||
this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this });
|
this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this });
|
||||||
this._panelManager = panelManager;
|
this._panelManager = panelManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTabLabel() {
|
getTabLabel() {
|
||||||
return 'Alert';
|
return 'Alert';
|
||||||
}
|
}
|
||||||
|
|
||||||
getDashboardUID() {
|
getDashboardUID() {
|
||||||
const dashboard = getDashboardSceneFor(this._panelManager);
|
const dashboard = this.getDashboard();
|
||||||
return dashboard.state.uid!;
|
return dashboard.state.uid!;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPanelId() {
|
getDashboard() {
|
||||||
|
return getDashboardSceneFor(this._panelManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLegacyPanelId() {
|
||||||
return getPanelIdForVizPanel(this._panelManager.state.panel);
|
return getPanelIdForVizPanel(this._panelManager.state.panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCanCreateRules() {
|
||||||
|
const rulesPermissions = getRulesPermissions('grafana');
|
||||||
|
return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create);
|
||||||
|
}
|
||||||
|
|
||||||
get panelManager() {
|
get panelManager() {
|
||||||
return this._panelManager;
|
return this._panelManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get panel() {
|
||||||
|
return this._panelManager.state.panel;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDataAlertingTab>) {
|
export function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDataAlertingTab>) {
|
||||||
const { model } = props;
|
const { model } = props;
|
||||||
|
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const { errors, loading, rules } = usePanelCombinedRules({
|
const { errors, loading, rules } = usePanelCombinedRules({
|
||||||
dashboardUID: model.getDashboardUID(),
|
dashboardUID: model.getDashboardUID(),
|
||||||
panelId: model.getPanelId(),
|
panelId: model.getLegacyPanelId(),
|
||||||
poll: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const alert = errors.length ? (
|
const alert = errors.length ? (
|
||||||
@@ -66,19 +86,36 @@ function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDataAlerti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { panel } = model;
|
||||||
|
const canCreateRules = model.getCanCreateRules();
|
||||||
|
|
||||||
if (rules.length) {
|
if (rules.length) {
|
||||||
return <RulesTable rules={rules} />;
|
return (
|
||||||
|
<>
|
||||||
|
<RulesTable rules={rules} />
|
||||||
|
{canCreateRules && <ScenesNewRuleFromPanelButton className={styles.newButton} panel={panel} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this is the tricky part, converting queries and such to pre populate the new alert form when clicking the button
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.noRulesWrapper}>
|
||||||
<p>There are no alert rules linked to this panel.</p>
|
<p>There are no alert rules linked to this panel.</p>
|
||||||
<button>New alert placeholder</button>
|
{canCreateRules && <ScenesNewRuleFromPanelButton panel={panel}></ScenesNewRuleFromPanelButton>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
newButton: css({
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
}),
|
||||||
|
noRulesWrapper: css({
|
||||||
|
margin: theme.spacing(2),
|
||||||
|
backgroundColor: theme.colors.background.secondary,
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
}),
|
||||||
|
});
|
||||||
interface PanelDataAlertingTabHeaderProps extends PanelDataTabHeaderProps {
|
interface PanelDataAlertingTabHeaderProps extends PanelDataTabHeaderProps {
|
||||||
model: PanelDataAlertingTab;
|
model: PanelDataAlertingTab;
|
||||||
}
|
}
|
||||||
@@ -88,7 +125,7 @@ function AlertingTab(props: PanelDataAlertingTabHeaderProps) {
|
|||||||
|
|
||||||
const { rules } = usePanelCombinedRules({
|
const { rules } = usePanelCombinedRules({
|
||||||
dashboardUID: model.getDashboardUID(),
|
dashboardUID: model.getDashboardUID(),
|
||||||
panelId: model.getPanelId(),
|
panelId: model.getLegacyPanelId(),
|
||||||
poll: false,
|
poll: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`PanelAlertTabContent Will render alerts belonging to panel and a button to create alert from panel queries 1`] = `
|
||||||
|
{
|
||||||
|
"annotations": [
|
||||||
|
{
|
||||||
|
"key": "__dashboardUid__",
|
||||||
|
"value": "12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "__panelId__",
|
||||||
|
"value": "34",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"condition": "C",
|
||||||
|
"name": "mypanel",
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"datasourceUid": "mock-ds-2",
|
||||||
|
"model": {
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "mock-ds-2",
|
||||||
|
},
|
||||||
|
"expr": "sum(some_metric [15s])) by (app)",
|
||||||
|
"interval": "",
|
||||||
|
"intervalMs": 15000,
|
||||||
|
"refId": "A",
|
||||||
|
},
|
||||||
|
"queryType": "",
|
||||||
|
"refId": "A",
|
||||||
|
"relativeTimeRange": {
|
||||||
|
"from": 21600,
|
||||||
|
"to": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasourceUid": "__expr__",
|
||||||
|
"model": {
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"evaluator": {
|
||||||
|
"params": [],
|
||||||
|
"type": "gt",
|
||||||
|
},
|
||||||
|
"operator": {
|
||||||
|
"type": "and",
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"params": [
|
||||||
|
"B",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reducer": {
|
||||||
|
"params": [],
|
||||||
|
"type": "last",
|
||||||
|
},
|
||||||
|
"type": "query",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"datasource": {
|
||||||
|
"type": "__expr__",
|
||||||
|
"uid": "__expr__",
|
||||||
|
},
|
||||||
|
"expression": "A",
|
||||||
|
"reducer": "last",
|
||||||
|
"refId": "B",
|
||||||
|
"type": "reduce",
|
||||||
|
},
|
||||||
|
"queryType": "",
|
||||||
|
"refId": "B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasourceUid": "__expr__",
|
||||||
|
"model": {
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"evaluator": {
|
||||||
|
"params": [
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
"type": "gt",
|
||||||
|
},
|
||||||
|
"operator": {
|
||||||
|
"type": "and",
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"params": [
|
||||||
|
"C",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reducer": {
|
||||||
|
"params": [],
|
||||||
|
"type": "last",
|
||||||
|
},
|
||||||
|
"type": "query",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"datasource": {
|
||||||
|
"type": "__expr__",
|
||||||
|
"uid": "__expr__",
|
||||||
|
},
|
||||||
|
"expression": "B",
|
||||||
|
"refId": "C",
|
||||||
|
"type": "threshold",
|
||||||
|
},
|
||||||
|
"queryType": "",
|
||||||
|
"refId": "C",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"type": "grafana",
|
||||||
|
}
|
||||||
|
`;
|
||||||
Reference in New Issue
Block a user