DashboardScene: Alert states data layer (#77945)

* Add AlertStates data topic

* DashboardScene: Alert states data layer

* TMP package json

* Remove duplicated function

* Use latest scenes canry

* Use latest scenes and add transformation test
This commit is contained in:
Dominik Prokop 2023-11-14 09:08:06 +01:00 committed by GitHub
parent ea12eecac5
commit a3ae9d418d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 287 additions and 17 deletions

View File

@ -2873,7 +2873,9 @@ exports[`better eslint`] = {
],
"public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

View File

@ -254,7 +254,7 @@
"@grafana/lezer-traceql": "0.0.10",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^1.21.1",
"@grafana/scenes": "^1.22.0",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@kusto/monaco-kusto": "^7.4.0",

View File

@ -17,6 +17,7 @@ export interface DataSourceRef extends SchemaDataSourceRef {}
*/
export enum DataTopic {
Annotations = 'annotations',
AlertStates = 'alertStates',
}
/**

View File

@ -0,0 +1,227 @@
import { from, map, Unsubscribable, Observable } from 'rxjs';
import { AlertState, AlertStateInfo, DataTopic, LoadingState, toDataFrame } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import {
SceneDataLayerBase,
SceneDataLayerProvider,
SceneDataLayerProviderState,
sceneGraph,
SceneTimeRangeLike,
} from '@grafana/scenes';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
import { getMessageFromError } from 'app/core/utils/errors';
import { Annotation } from 'app/features/alerting/unified/utils/constants';
import { isAlertingRule } from 'app/features/alerting/unified/utils/rules';
import { dispatch } from 'app/store/store';
import { AccessControlAction } from 'app/types';
import { PromAlertingRuleState, PromRulesResponse } from 'app/types/unified-alerting-dto';
import { getDashboardSceneFor } from '../utils/utils';
interface AlertStatesDataLayerState extends SceneDataLayerProviderState {}
export class AlertStatesDataLayer
extends SceneDataLayerBase<AlertStatesDataLayerState>
implements SceneDataLayerProvider
{
private hasAlertRules = true;
private _timeRangeSub: Unsubscribable | undefined;
public topic = DataTopic.AlertStates;
public constructor(initialState: AlertStatesDataLayerState) {
super({
isEnabled: true,
...initialState,
isHidden: true,
});
}
public onEnable(): void {
const timeRange = sceneGraph.getTimeRange(this);
this._timeRangeSub = timeRange.subscribeToState(() => {
this.runWithTimeRange(timeRange);
});
}
public onDisable(): void {
this._timeRangeSub?.unsubscribe();
}
public runLayer() {
const timeRange = sceneGraph.getTimeRange(this);
this.runWithTimeRange(timeRange);
}
private async runWithTimeRange(timeRange: SceneTimeRangeLike) {
const dashboard = getDashboardSceneFor(this);
const { uid, id } = dashboard.state;
if (this.querySub) {
this.querySub.unsubscribe();
}
if (!this.canWork(timeRange)) {
return;
}
let alerStatesExecution: Observable<AlertStateInfo[]> | undefined;
if (this.isUsingLegacyAlerting()) {
alerStatesExecution = from(
getBackendSrv().get(
'/api/alerts/states-for-dashboard',
{
dashboardId: id,
},
`dashboard-query-runner-alert-states-${id}`
)
).pipe(map((alertStates) => alertStates));
} else {
alerStatesExecution = from(
getBackendSrv().get(
'/api/prometheus/grafana/api/v1/rules',
{
dashboard_uid: uid!,
},
`dashboard-query-runner-unified-alert-states-${id}`
)
).pipe(
map((result: PromRulesResponse) => {
if (result.status === 'success') {
this.hasAlertRules = false;
const panelIdToAlertState: Record<number, AlertStateInfo> = {};
result.data.groups.forEach((group) =>
group.rules.forEach((rule) => {
if (isAlertingRule(rule) && rule.annotations && rule.annotations[Annotation.panelID]) {
this.hasAlertRules = true;
const panelId = Number(rule.annotations[Annotation.panelID]);
const state = promAlertStateToAlertState(rule.state);
// there can be multiple alerts per panel, so we make sure we get the most severe state:
// alerting > pending > ok
if (!panelIdToAlertState[panelId]) {
panelIdToAlertState[panelId] = {
state,
id: Object.keys(panelIdToAlertState).length,
panelId,
dashboardId: id!,
};
} else if (
state === AlertState.Alerting &&
panelIdToAlertState[panelId].state !== AlertState.Alerting
) {
panelIdToAlertState[panelId].state = AlertState.Alerting;
} else if (
state === AlertState.Pending &&
panelIdToAlertState[panelId].state !== AlertState.Alerting &&
panelIdToAlertState[panelId].state !== AlertState.Pending
) {
panelIdToAlertState[panelId].state = AlertState.Pending;
}
}
})
);
return Object.values(panelIdToAlertState);
}
throw new Error(`Unexpected alert rules response.`);
})
);
}
this.querySub = alerStatesExecution.subscribe({
next: (stateUpdate) => {
this.publishResults(
{
state: LoadingState.Done,
series: [toDataFrame(stateUpdate)],
timeRange: timeRange.state.value,
},
DataTopic.AlertStates
);
},
error: (err) => {
this.handleError(err);
this.publishResults(
{
state: LoadingState.Error,
series: [],
errors: [
{
message: getMessageFromError(err),
},
],
timeRange: timeRange.state.value,
},
DataTopic.AlertStates
);
},
});
}
private canWork(timeRange: SceneTimeRangeLike): boolean {
const dashboard = getDashboardSceneFor(this);
const { uid, id } = dashboard.state;
if (this.isUsingLegacyAlerting()) {
if (!id) {
return false;
}
if (timeRange.state.value.raw.to !== 'now') {
return false;
}
return true;
} else {
if (!uid) {
return false;
}
// Cannot fetch rules while on a public dashboard since it's unauthenticated
if (config.publicDashboardAccessToken) {
return false;
}
if (timeRange.state.value.raw.to !== 'now') {
return false;
}
if (this.hasAlertRules === false) {
return false;
}
const hasRuleReadPermission =
contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) &&
contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalRead);
if (!hasRuleReadPermission) {
return false;
}
return true;
}
}
private isUsingLegacyAlerting(): boolean {
return !config.unifiedAlertingEnabled;
}
private handleError = (err: unknown) => {
const notification = createErrorNotification('AlertStatesDataLayer', getMessageFromError(err));
dispatch(notifyApp(notification));
};
}
export function promAlertStateToAlertState(state: PromAlertingRuleState): AlertState {
if (state === PromAlertingRuleState.Firing) {
return AlertState.Alerting;
} else if (state === PromAlertingRuleState.Pending) {
return AlertState.Pending;
}
return AlertState.OK;
}

View File

@ -28,6 +28,7 @@ import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { createPanelSaveModel } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { DashboardDataDTO } from 'app/types';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
@ -743,6 +744,34 @@ describe('transformSaveModelToScene', () => {
expect(dataLayers.state.layers[3].state.isHidden).toBe(true);
});
});
describe('Alerting data layer', () => {
it('Should add alert states data layer if unified alerting enabled', () => {
config.unifiedAlertingEnabled = true;
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect(scene.state.controls![2]).toBeInstanceOf(SceneDataLayerControls);
const dataLayers = scene.state.$data as SceneDataLayers;
expect(dataLayers.state.layers).toHaveLength(5);
expect(dataLayers.state.layers[4].state.name).toBe('Alert States');
});
it('Should add alert states data layer if any panel has a legacy alert defined', () => {
config.unifiedAlertingEnabled = false;
const dashboard = { ...dashboard_to_load1 } as unknown as DashboardDataDTO;
dashboard.panels![0].alert = {};
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect(scene.state.controls![2]).toBeInstanceOf(SceneDataLayerControls);
const dataLayers = scene.state.$data as SceneDataLayers;
expect(dataLayers.state.layers).toHaveLength(5);
expect(dataLayers.state.layers[4].state.name).toBe('Alert States');
});
});
});
function buildGridItemForTest(saveModel: Partial<Panel>): { gridItem: SceneGridItem; vizPanel: VizPanel } {

View File

@ -1,4 +1,5 @@
import { AdHocVariableModel, TypedVariableModel, VariableModel } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
VizPanel,
SceneTimePicker,
@ -29,6 +30,7 @@ import {
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { DashboardDTO } from 'app/types';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
@ -187,6 +189,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
layers = oldModel.annotations?.list.map((a) => {
// Each annotation query is an individual data layer
return new DashboardAnnotationsDataLayer({
key: `annnotations-${a.name}`,
query: a,
name: a.name,
isEnabled: Boolean(a.enable),
@ -195,6 +198,22 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
});
}
let shouldUseAlertStatesLayer = config.unifiedAlertingEnabled;
if (!shouldUseAlertStatesLayer) {
if (oldModel.panels.find((panel) => Boolean(panel.alert))) {
shouldUseAlertStatesLayer = true;
}
}
if (shouldUseAlertStatesLayer) {
layers.push(
new AlertStatesDataLayer({
key: 'alert-states',
name: 'Alert States',
})
);
}
let controls: SceneObject[] = [
new VariableValueSelectors({}),
...filtersSets,

View File

@ -6,8 +6,9 @@ import { config, getBackendSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { Annotation } from 'app/features/alerting/unified/utils/constants';
import { isAlertingRule } from 'app/features/alerting/unified/utils/rules';
import { promAlertStateToAlertState } from 'app/features/dashboard-scene/scene/AlertStatesDataLayer';
import { AccessControlAction } from 'app/types';
import { PromAlertingRuleState, PromRulesResponse } from 'app/types/unified-alerting-dto';
import { PromRulesResponse } from 'app/types/unified-alerting-dto';
import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorker, DashboardQueryRunnerWorkerResult } from './types';
import { emptyResult, handleDashboardQueryRunnerWorkerError } from './utils';
@ -105,12 +106,3 @@ export class UnifiedAlertStatesWorker implements DashboardQueryRunnerWorker {
);
}
}
function promAlertStateToAlertState(state: PromAlertingRuleState): AlertState {
if (state === PromAlertingRuleState.Firing) {
return AlertState.Alerting;
} else if (state === PromAlertingRuleState.Pending) {
return AlertState.Pending;
}
return AlertState.OK;
}

View File

@ -3320,9 +3320,9 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes@npm:^1.21.1":
version: 1.21.1
resolution: "@grafana/scenes@npm:1.21.1"
"@grafana/scenes@npm:^1.22.0":
version: 1.22.0
resolution: "@grafana/scenes@npm:1.22.0"
dependencies:
"@grafana/e2e-selectors": "npm:10.0.2"
react-grid-layout: "npm:1.3.4"
@ -3334,7 +3334,7 @@ __metadata:
"@grafana/runtime": 10.0.3
"@grafana/schema": 10.0.3
"@grafana/ui": 10.0.3
checksum: f9621b0edcc5a9da2cfeac679bf9ea8d2ae6fc64c635f5bf8ee90c47cdf7a8e8799b4ef4d41b1fdee056371e9f5bfc73f5e5b2dc23852a1b05963748559fd6e9
checksum: 6067bccec76de3f5aeab15c943095cf11e407b5734ddab4dc52ed40e469f65b096d69b7b0f46e5abe0a51c6691651c8ca8609177b95a32a2419239068a337952
languageName: node
linkType: hard
@ -17325,7 +17325,7 @@ __metadata:
"@grafana/lezer-traceql": "npm:0.0.10"
"@grafana/monaco-logql": "npm:^0.0.7"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:^1.21.1"
"@grafana/scenes": "npm:^1.22.0"
"@grafana/schema": "workspace:*"
"@grafana/tsconfig": "npm:^1.3.0-rc1"
"@grafana/ui": "workspace:*"