diff --git a/.betterer.results b/.betterer.results index cd5eb5be4d0..ccb647cb185 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/package.json b/package.json index f1884add019..43ef7ab0db5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-data/src/types/query.ts b/packages/grafana-data/src/types/query.ts index 1ec157c0a57..30579b1e7dc 100644 --- a/packages/grafana-data/src/types/query.ts +++ b/packages/grafana-data/src/types/query.ts @@ -17,6 +17,7 @@ export interface DataSourceRef extends SchemaDataSourceRef {} */ export enum DataTopic { Annotations = 'annotations', + AlertStates = 'alertStates', } /** diff --git a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts new file mode 100644 index 00000000000..b7e3f768dac --- /dev/null +++ b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts @@ -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 + 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 | 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 = {}; + + 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; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 10671a0fc27..2912d9951f4 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -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): { gridItem: SceneGridItem; vizPanel: VizPanel } { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index dafb9bd2ad4..637e2b940be 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -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, diff --git a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts index 0591d0b8ae0..d0c13024e2d 100644 --- a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts +++ b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts @@ -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; -} diff --git a/yarn.lock b/yarn.lock index cfb2d13997a..a78a44ad534 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:*"