Alerting: make alert state indicator in panel header work with Grafana 8 alerts (#38713)

This commit is contained in:
Domas
2021-10-11 16:55:45 +03:00
committed by GitHub
parent 8318e45452
commit 571257226e
23 changed files with 442 additions and 58 deletions

View File

@@ -12,7 +12,7 @@ jest.mock('@grafana/runtime', () => ({
}));
function getDefaultOptions(): DashboardQueryRunnerOptions {
const dashboard: any = { id: 'an id' };
const dashboard: any = { id: 'an id', panels: [{ alert: {} }] };
const range = getDefaultTimeRange();
return { dashboard, range };
@@ -57,6 +57,14 @@ describe('AlertStatesWorker', () => {
});
});
describe('when canWork is called for dashboard with no alert panels', () => {
it('then it should return false', () => {
const options = getDefaultOptions();
options.dashboard.panels.forEach((panel) => delete panel.alert);
expect(worker.canWork(options)).toBe(false);
});
});
describe('when run is called with incorrect props', () => {
it('then it should return the correct results', async () => {
const { getMock, options } = getTestContext();
@@ -74,8 +82,8 @@ describe('AlertStatesWorker', () => {
describe('when run is called with correct props and request is successful', () => {
it('then it should return the correct results', async () => {
const getResults: AlertStateInfo[] = [
{ id: 1, state: AlertState.Alerting, newStateDate: '2021-01-01', dashboardId: 1, panelId: 1 },
{ id: 2, state: AlertState.Alerting, newStateDate: '2021-02-01', dashboardId: 1, panelId: 2 },
{ id: 1, state: AlertState.Alerting, dashboardId: 1, panelId: 1 },
{ id: 2, state: AlertState.Alerting, dashboardId: 1, panelId: 2 },
];
const { getMock, options } = getTestContext();
getMock.mockResolvedValue(getResults);

View File

@@ -14,6 +14,11 @@ export class AlertStatesWorker implements DashboardQueryRunnerWorker {
return false;
}
// if dashboard has no alerts, no point to query alert states
if (!dashboard.panels.find((panel) => !!panel.alert)) {
return false;
}
return true;
}

View File

@@ -23,8 +23,8 @@ function getTestContext() {
const runner = createDashboardQueryRunner({ dashboard: options.dashboard, timeSrv: timeSrvMock });
const getResults: AlertStateInfo[] = [
{ id: 1, state: AlertState.Alerting, newStateDate: '2021-01-01', dashboardId: 1, panelId: 1 },
{ id: 2, state: AlertState.Alerting, newStateDate: '2021-02-01', dashboardId: 1, panelId: 2 },
{ id: 1, state: AlertState.Alerting, dashboardId: 1, panelId: 1 },
{ id: 2, state: AlertState.Alerting, dashboardId: 1, panelId: 2 },
];
const getMock = jest.spyOn(backendSrv, 'get').mockResolvedValue(getResults);
const executeAnnotationQueryMock = jest
@@ -291,7 +291,6 @@ function getExpectedForAllResult(): DashboardQueryRunnerResult {
alertState: {
dashboardId: 1,
id: 1,
newStateDate: '2021-01-01',
panelId: 1,
state: AlertState.Alerting,
},

View File

@@ -17,6 +17,8 @@ import { getAnnotationsByPanelId } from './utils';
import { DashboardModel } from '../../../dashboard/state';
import { getTimeSrv, TimeSrv } from '../../../dashboard/services/TimeSrv';
import { RefreshEvent } from '../../../../types/events';
import { config } from 'app/core/config';
import { UnifiedAlertStatesWorker } from './UnifiedAlertStatesWorker';
class DashboardQueryRunnerImpl implements DashboardQueryRunner {
private readonly results: ReplaySubject<DashboardQueryRunnerWorkerResult>;
@@ -29,7 +31,7 @@ class DashboardQueryRunnerImpl implements DashboardQueryRunner {
private readonly dashboard: DashboardModel,
private readonly timeSrv: TimeSrv = getTimeSrv(),
private readonly workers: DashboardQueryRunnerWorker[] = [
new AlertStatesWorker(),
config.featureToggles.ngalert ? new UnifiedAlertStatesWorker() : new AlertStatesWorker(),
new SnapshotWorker(),
new AnnotationsWorker(),
]

View File

@@ -0,0 +1,200 @@
import { AlertState, getDefaultTimeRange, TimeRange } from '@grafana/data';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardQueryRunnerOptions } from './types';
import { UnifiedAlertStatesWorker } from './UnifiedAlertStatesWorker';
import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput';
import * as store from '../../../../store/store';
import { PromAlertingRuleState, PromRuleDTO, PromRulesResponse, PromRuleType } from 'app/types/unified-alerting-dto';
import { Annotation } from 'app/features/alerting/unified/utils/constants';
import { lastValueFrom } from 'rxjs';
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
getBackendSrv: () => backendSrv,
}));
function getDefaultOptions(): DashboardQueryRunnerOptions {
const dashboard: any = { id: 'an id', uid: 'a uid' };
const range = getDefaultTimeRange();
return { dashboard, range };
}
function getTestContext() {
jest.clearAllMocks();
const dispatchMock = jest.spyOn(store, 'dispatch');
const options = getDefaultOptions();
const getMock = jest.spyOn(backendSrv, 'get');
return { getMock, options, dispatchMock };
}
describe('UnifiedAlertStatesWorker', () => {
const worker = new UnifiedAlertStatesWorker();
describe('when canWork is called with correct props', () => {
it('then it should return true', () => {
const options = getDefaultOptions();
expect(worker.canWork(options)).toBe(true);
});
});
describe('when canWork is called with no dashboard id', () => {
it('then it should return false', () => {
const dashboard: any = {};
const options = { ...getDefaultOptions(), dashboard };
expect(worker.canWork(options)).toBe(false);
});
});
describe('when canWork is called with wrong range', () => {
it('then it should return false', () => {
const defaultRange = getDefaultTimeRange();
const range: TimeRange = { ...defaultRange, raw: { ...defaultRange.raw, to: 'now-6h' } };
const options = { ...getDefaultOptions(), range };
expect(worker.canWork(options)).toBe(false);
});
});
describe('when run is called with incorrect props', () => {
it('then it should return the correct results', async () => {
const { getMock, options } = getTestContext();
const dashboard: any = {};
await expect(worker.work({ ...options, dashboard })).toEmitValuesWith((received) => {
expect(received).toHaveLength(1);
const results = received[0];
expect(results).toEqual({ alertStates: [], annotations: [] });
expect(getMock).not.toHaveBeenCalled();
});
});
});
describe('when run repeatedly for the same dashboard and no alert rules are found', () => {
it('then canWork should start returning false', async () => {
const worker = new UnifiedAlertStatesWorker();
const getResults: PromRulesResponse = {
status: 'success',
data: {
groups: [],
},
};
const { getMock, options } = getTestContext();
getMock.mockResolvedValue(getResults);
expect(worker.canWork(options)).toBe(true);
await lastValueFrom(worker.work(options));
expect(worker.canWork(options)).toBe(false);
});
});
describe('when run is called with correct props and request is successful', () => {
function mockPromRuleDTO(overrides: Partial<PromRuleDTO>): PromRuleDTO {
return {
alerts: [],
health: 'ok',
name: 'foo',
query: 'foo',
type: PromRuleType.Alerting,
state: PromAlertingRuleState.Firing,
labels: {},
annotations: {},
...overrides,
};
}
it('then it should return the correct results', async () => {
const getResults: PromRulesResponse = {
status: 'success',
data: {
groups: [
{
name: 'group',
file: '',
interval: 1,
rules: [
mockPromRuleDTO({
state: PromAlertingRuleState.Firing,
annotations: {
[Annotation.dashboardUID]: 'a uid',
[Annotation.panelID]: '1',
},
}),
mockPromRuleDTO({
state: PromAlertingRuleState.Inactive,
annotations: {
[Annotation.dashboardUID]: 'a uid',
[Annotation.panelID]: '2',
},
}),
mockPromRuleDTO({
state: PromAlertingRuleState.Pending,
annotations: {
[Annotation.dashboardUID]: 'a uid',
[Annotation.panelID]: '2',
},
}),
],
},
],
},
};
const { getMock, options } = getTestContext();
getMock.mockResolvedValue(getResults);
await expect(worker.work(options)).toEmitValuesWith((received) => {
expect(received).toHaveLength(1);
const results = received[0];
expect(results).toEqual({
alertStates: [
{ id: 0, state: AlertState.Alerting, dashboardId: 'an id', panelId: 1 },
{ id: 1, state: AlertState.Pending, dashboardId: 'an id', panelId: 2 },
],
annotations: [],
});
});
expect(getMock).toHaveBeenCalledTimes(1);
expect(getMock).toHaveBeenCalledWith(
'/api/prometheus/grafana/api/v1/rules',
{ dashboard_uid: 'a uid' },
'dashboard-query-runner-unified-alert-states-an id'
);
});
});
describe('when run is called with correct props and request fails', () => {
silenceConsoleOutput();
it('then it should return the correct results', async () => {
const { getMock, options, dispatchMock } = getTestContext();
getMock.mockRejectedValue({ message: 'An error' });
await expect(worker.work(options)).toEmitValuesWith((received) => {
expect(received).toHaveLength(1);
const results = received[0];
expect(results).toEqual({ alertStates: [], annotations: [] });
expect(getMock).toHaveBeenCalledTimes(1);
expect(dispatchMock).toHaveBeenCalledTimes(1);
});
});
});
describe('when run is called with correct props and request is cancelled', () => {
silenceConsoleOutput();
it('then it should return the correct results', async () => {
const { getMock, options, dispatchMock } = getTestContext();
getMock.mockRejectedValue({ cancelled: true });
await expect(worker.work(options)).toEmitValuesWith((received) => {
expect(received).toHaveLength(1);
const results = received[0];
expect(results).toEqual({ alertStates: [], annotations: [] });
expect(getMock).toHaveBeenCalledTimes(1);
expect(dispatchMock).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,99 @@
import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorker, DashboardQueryRunnerWorkerResult } from './types';
import { from, Observable } from 'rxjs';
import { getBackendSrv } from '@grafana/runtime';
import { catchError, map } from 'rxjs/operators';
import { emptyResult, handleDashboardQueryRunnerWorkerError } from './utils';
import { PromAlertingRuleState, PromRulesResponse } from 'app/types/unified-alerting-dto';
import { AlertState, AlertStateInfo } from '@grafana/data';
import { isAlertingRule } from 'app/features/alerting/unified/utils/rules';
import { Annotation } from 'app/features/alerting/unified/utils/constants';
export class UnifiedAlertStatesWorker implements DashboardQueryRunnerWorker {
// maps dashboard uid to wether it has alert rules.
// if it is determined that a dashboard does not have alert rules,
// further attempts to get alert states for it will not be made
private hasAlertRules: Record<string, boolean> = {};
canWork({ dashboard, range }: DashboardQueryRunnerOptions): boolean {
if (!dashboard.uid) {
return false;
}
if (range.raw.to !== 'now') {
return false;
}
if (this.hasAlertRules[dashboard.uid] === false) {
return false;
}
return true;
}
work(options: DashboardQueryRunnerOptions): Observable<DashboardQueryRunnerWorkerResult> {
if (!this.canWork(options)) {
return emptyResult();
}
const { dashboard } = options;
return from(
getBackendSrv().get(
'/api/prometheus/grafana/api/v1/rules',
{
dashboard_uid: dashboard.uid,
},
`dashboard-query-runner-unified-alert-states-${dashboard.id}`
)
).pipe(
map((result: PromRulesResponse) => {
if (result.status === 'success') {
this.hasAlertRules[dashboard.uid] = 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[dashboard.uid] = 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: dashboard.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 { alertStates: Object.values(panelIdToAlertState), annotations: [] };
}
throw new Error(`Unexpected alert rules response.`);
}),
catchError(handleDashboardQueryRunnerWorkerError)
);
}
}
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

@@ -52,6 +52,7 @@ export function getDefaultOptions(): DashboardQueryRunnerOptions {
subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
publish: jest.fn(),
},
panels: [{ alert: {} } as any],
};
const range = getDefaultTimeRange();