Alerting: Promote new alerting detail view (#84277)

This commit is contained in:
Gilles De Mey 2024-03-14 15:18:01 +01:00 committed by GitHub
parent 8690a42e33
commit 336acaf0bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 44 additions and 723 deletions

View File

@ -2115,21 +2115,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"]
],
"public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]

View File

@ -152,7 +152,6 @@ export interface FeatureToggles {
canvasPanelPanZoom?: boolean;
logsInfiniteScrolling?: boolean;
flameGraphItemCollapsing?: boolean;
alertingDetailsViewV2?: boolean;
datatrails?: boolean;
alertingSimplifiedRouting?: boolean;
logRowsPopoverMenu?: boolean;

View File

@ -991,14 +991,6 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityTracesAndProfilingSquad,
},
{
Name: "alertingDetailsViewV2",
Description: "Enables the preview of the new alert details view",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaAlertingSquad,
HideFromDocs: true,
},
{
Name: "datatrails",
Description: "Enables the new core app datatrails",

View File

@ -133,7 +133,6 @@ ssoSettingsApi,preview,@grafana/identity-access-team,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
logsInfiniteScrolling,experimental,@grafana/observability-logs,false,false,true
flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,false,false,true
alertingDetailsViewV2,experimental,@grafana/alerting-squad,false,false,true
datatrails,experimental,@grafana/dashboards-squad,false,false,true
alertingSimplifiedRouting,preview,@grafana/alerting-squad,false,false,false
logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
133 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
134 logsInfiniteScrolling experimental @grafana/observability-logs false false true
135 flameGraphItemCollapsing experimental @grafana/observability-traces-and-profiling false false true
alertingDetailsViewV2 experimental @grafana/alerting-squad false false true
136 datatrails experimental @grafana/dashboards-squad false false true
137 alertingSimplifiedRouting preview @grafana/alerting-squad false false false
138 logRowsPopoverMenu GA @grafana/observability-logs false false true

View File

@ -543,10 +543,6 @@ const (
// Allow collapsing of flame graph items
FlagFlameGraphItemCollapsing = "flameGraphItemCollapsing"
// FlagAlertingDetailsViewV2
// Enables the preview of the new alert details view
FlagAlertingDetailsViewV2 = "alertingDetailsViewV2"
// FlagDatatrails
// Enables the new core app datatrails
FlagDatatrails = "datatrails"

View File

@ -999,7 +999,8 @@
"metadata": {
"name": "alertingDetailsViewV2",
"resourceVersion": "1709648236447",
"creationTimestamp": "2024-03-05T14:17:16Z"
"creationTimestamp": "2024-03-05T14:17:16Z",
"deletionTimestamp": "2024-03-12T12:33:03Z"
},
"spec": {
"description": "Enables the preview of the new alert details view",

View File

@ -1,40 +1,24 @@
import React from 'react';
import { NavModelItem } from '@grafana/data';
import { config, isFetchError } from '@grafana/runtime';
import { isFetchError } from '@grafana/runtime';
import { Alert, withErrorBoundary } from '@grafana/ui';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { AlertRuleProvider } from './components/rule-viewer/v2/RuleContext';
import { AlertRuleProvider } from './components/rule-viewer/RuleContext';
import DetailView from './components/rule-viewer/RuleViewer';
import { useCombinedRule } from './hooks/useCombinedRule';
import { stringifyErrorLike } from './utils/misc';
import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id';
const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1'));
const DetailViewV2 = React.lazy(() => import('./components/rule-viewer/v2/RuleViewer.v2'));
type RuleViewerProps = GrafanaRouteComponentProps<{
id: string;
sourceName: string;
}>;
const newAlertDetailView = Boolean(config.featureToggles?.alertingDetailsViewV2) === true;
const RuleViewer = (props: RuleViewerProps): JSX.Element => {
return newAlertDetailView ? <RuleViewerV2Wrapper {...props} /> : <RuleViewerV1Wrapper {...props} />;
};
export const defaultPageNav: NavModelItem = {
id: 'alert-rule-view',
text: '',
};
const RuleViewerV1Wrapper = (props: RuleViewerProps) => <DetailViewV1 {...props} />;
const RuleViewerV2Wrapper = (props: RuleViewerProps) => {
const id = getRuleIdFromPathname(props.match.params);
// we convert the stringified ID to a rule identifier object which contains additional
@ -69,7 +53,7 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => {
if (rule) {
return (
<AlertRuleProvider identifier={identifier} rule={rule}>
<DetailViewV2 />
<DetailView />
</AlertRuleProvider>
);
}
@ -82,6 +66,11 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => {
);
};
export const defaultPageNav: NavModelItem = {
id: 'alert-rule-view',
text: '',
};
interface ErrorMessageProps {
error: unknown;
}

View File

@ -5,12 +5,12 @@ import { Dropdown, LinkButton, Menu } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../../utils/misc';
import * as ruleId from '../../../utils/rule-id';
import { createUrl } from '../../../utils/url';
import MoreButton from '../../MoreButton';
import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { createUrl } from '../../utils/url';
import MoreButton from '../MoreButton';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
import { useAlertRule } from './RuleContext';

View File

@ -4,9 +4,9 @@ import { ConfirmModal } from '@grafana/ui';
import { dispatch } from 'app/store/store';
import { CombinedRule } from 'app/types/unified-alerting';
import { deleteRuleAction } from '../../../state/actions';
import { getRulesSourceName } from '../../../utils/datasource';
import { fromRulerRule } from '../../../utils/rule-id';
import { deleteRuleAction } from '../../state/actions';
import { getRulesSourceName } from '../../utils/datasource';
import { fromRulerRule } from '../../utils/rule-id';
type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void];

View File

@ -9,12 +9,12 @@ import { backendSrv } from 'app/core/services/backend_srv';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../../mocks';
import { Annotation } from '../../../utils/constants';
import * as ruleId from '../../../utils/rule-id';
import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks';
import { Annotation } from '../../utils/constants';
import * as ruleId from '../../utils/rule-id';
import { AlertRuleProvider } from './RuleContext';
import RuleViewer from './RuleViewer.v2';
import RuleViewer from './RuleViewer';
import { createMockGrafanaServer } from './__mocks__/server';
// metadata and interactive elements

View File

@ -9,28 +9,28 @@ import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import { defaultPageNav } from '../../../RuleViewer';
import { Annotation } from '../../../utils/constants';
import { makeDashboardLink, makePanelLink } from '../../../utils/misc';
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule, isRecordingRule } from '../../../utils/rules';
import { createUrl } from '../../../utils/url';
import { AlertLabels } from '../../AlertLabels';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
import { WithReturnButton } from '../../WithReturnButton';
import { decodeGrafanaNamespace } from '../../expressions/util';
import { RedirectToCloneRule } from '../../rules/CloneRule';
import { FederatedRuleWarning } from '../FederatedRuleWarning';
import { Details } from '../tabs/Details';
import { History } from '../tabs/History';
import { InstancesList } from '../tabs/Instances';
import { QueryResults } from '../tabs/Query';
import { Routing } from '../tabs/Routing';
import { defaultPageNav } from '../../RuleViewer';
import { Annotation } from '../../utils/constants';
import { makeDashboardLink, makePanelLink } from '../../utils/misc';
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule, isRecordingRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
import { AlertLabels } from '../AlertLabels';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { WithReturnButton } from '../WithReturnButton';
import { decodeGrafanaNamespace } from '../expressions/util';
import { RedirectToCloneRule } from '../rules/CloneRule';
import { useAlertRulePageActions } from './Actions';
import { useDeleteModal } from './DeleteModal';
import { FederatedRuleWarning } from './FederatedRuleWarning';
import { useAlertRule } from './RuleContext';
import { RecordingBadge, StateBadge } from './StateBadges';
import { Details } from './tabs/Details';
import { History } from './tabs/History';
import { InstancesList } from './tabs/Instances';
import { QueryResults } from './tabs/Query';
import { Routing } from './tabs/Routing';
enum ActiveTab {
Query = 'query',

View File

@ -1,378 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { byRole, byText } from 'testing-library-selector';
import { PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions, locationService, setBackendSrv } from '@grafana/runtime';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { CombinedRule } from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto';
import { discoverFeatures } from '../../api/buildInfo';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { mockAlertRuleApi, setupMswServer } from '../../mockApi';
import {
getCloudRule,
getGrafanaRule,
grantUserPermissions,
mockDataSource,
mockPromAlertingRule,
mockRulerAlertingRule,
promRuleFromRulerRule,
} from '../../mocks';
import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi';
import { mockPluginSettings } from '../../mocks/plugins';
import { setupDataSources } from '../../testSetup/datasources';
import { SupportedPlugin } from '../../types/pluginBridges';
import * as ruleId from '../../utils/rule-id';
import { RuleViewer } from './RuleViewer.v1';
const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' }, { uid: 'test1', title: 'Test alert' });
const mockCloudRule = getCloudRule({ name: 'cloud test alert' });
const mockRoute = (id?: string): GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> => ({
route: {
path: '/',
component: RuleViewer,
},
queryParams: { returnTo: '/alerting/list' },
match: { params: { id: id ?? 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' },
history: locationService.getHistory(),
location: { pathname: '', hash: '', search: '', state: '' },
staticContext: {},
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(),
useReturnToPrevious: jest.fn(),
}));
jest.mock('../../hooks/useAbilities');
jest.mock('../../api/buildInfo');
const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
useAlertRuleAbility: jest.mocked(useAlertRuleAbility),
};
const ui = {
actionButtons: {
edit: byRole('link', { name: /edit/i }),
silence: byRole('link', { name: 'Silence' }),
},
moreButton: byRole('button', { name: /More/i }),
moreButtons: {
duplicate: byRole('menuitem', { name: /^Duplicate$/i }),
delete: byRole('menuitem', { name: /delete/i }),
},
loadingIndicator: byText(/Loading rule/i),
};
const renderRuleViewer = async (ruleId: string) => {
locationService.push(`/alerting/grafana/${ruleId}/view`);
render(
<TestProvider>
<RuleViewer {...mockRoute(ruleId)} />
</TestProvider>
);
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
};
const server = setupMswServer();
const user = userEvent.setup();
const dsName = 'prometheus';
const rulerRule = mockRulerAlertingRule({ alert: 'cloud test alert' });
const rulerRuleIdentifier = ruleId.fromRulerRule('prometheus', 'ns-default', 'group-default', rulerRule);
beforeAll(() => {
setBackendSrv(backendSrv);
const promDsSettings = mockDataSource({
name: dsName,
uid: dsName,
});
setupDataSources(promDsSettings);
});
beforeEach(() => {
// some action buttons need to check what Alertmanager setup we have for Grafana managed rules
mockAlertmanagerChoiceResponse(server, {
alertmanagersChoice: AlertmanagerChoice.Internal,
numExternalAlertmanagers: 1,
});
// we need to mock this one for the "declare incident" button
mockPluginSettings(server, SupportedPlugin.Incident);
mockAlertRuleApi(server).rulerRules('grafana', {
[mockGrafanaRule.namespace.name]: [
{ name: mockGrafanaRule.group.name, interval: '1m', rules: [mockGrafanaRule.rulerRule!] },
],
});
const { name, query, labels, annotations } = mockGrafanaRule;
mockAlertRuleApi(server).prometheusRuleNamespaces('grafana', {
data: {
groups: [
{
file: mockGrafanaRule.namespace.name,
interval: 60,
name: mockGrafanaRule.group.name,
rules: [mockPromAlertingRule({ name, query, labels, annotations })],
},
],
},
status: 'success',
});
mockAlertRuleApi(server).rulerRuleGroup(dsName, 'ns-default', 'group-default', {
name: 'group-default',
interval: '1m',
rules: [rulerRule],
});
mockAlertRuleApi(server).prometheusRuleNamespaces(dsName, {
data: {
groups: [
{
file: 'ns-default',
interval: 60,
name: 'group-default',
rules: [promRuleFromRulerRule(rulerRule, { state: PromAlertingRuleState.Inactive })],
},
],
},
status: 'success',
});
mocks.getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
pluginId: 'grafana-ml-app',
id: '1',
type: PluginExtensionTypes.link,
title: 'Run investigation',
category: 'Sift',
description: 'Run a Sift investigation for this alert',
onClick: jest.fn(),
},
],
});
});
describe('RuleViewer', () => {
let mockCombinedRule = jest.fn();
afterEach(() => {
mockCombinedRule.mockReset();
});
it('should render page with grafana alert', async () => {
mocks.useAlertRuleAbility.mockReturnValue([true, true]);
await renderRuleViewer('test1');
expect(screen.getByText(/test alert/i)).toBeInTheDocument();
});
it('should render page with cloud alert', async () => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
jest
.mocked(discoverFeatures)
.mockResolvedValue({ application: PromApplication.Mimir, features: { rulerApiEnabled: true } });
mocks.useAlertRuleAbility.mockReturnValue([true, true]);
await renderRuleViewer(ruleId.stringifyIdentifier(rulerRuleIdentifier));
expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument();
});
});
describe('RuleDetails RBAC', () => {
describe('Grafana rules action buttons in details', () => {
let mockCombinedRule = jest.fn();
beforeEach(() => {
// mockCombinedRule = jest.mocked(useCombinedRule);
});
afterEach(() => {
mockCombinedRule.mockReset();
});
it('Should render Edit button for users with the update permission', async () => {
// Arrange
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({
result: mockGrafanaRule as CombinedRule,
loading: false,
dispatched: true,
requestId: 'A',
error: undefined,
});
// Act
await renderRuleViewer('test1');
// Assert
expect(ui.actionButtons.edit.get()).toBeInTheDocument();
});
it('Should render Delete button for users with the delete permission', async () => {
// Arrange
mockCombinedRule.mockReturnValue({
result: mockGrafanaRule as CombinedRule,
loading: false,
dispatched: true,
requestId: 'A',
error: undefined,
});
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false];
});
// Act
await renderRuleViewer('test1');
await user.click(ui.moreButton.get());
// Assert
expect(ui.moreButtons.delete.get()).toBeInTheDocument();
});
it('Should not render Silence button for users wihout the instance create permission', async () => {
// Arrange
mockCombinedRule.mockReturnValue({
result: mockGrafanaRule as CombinedRule,
loading: false,
dispatched: true,
requestId: 'A',
error: undefined,
});
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
// Act
await renderRuleViewer('test1');
// Assert
await waitFor(() => {
expect(ui.actionButtons.silence.query()).not.toBeInTheDocument();
});
});
it('Should render Silence button for users with the instance create permissions', async () => {
// Arrange
mocks.useAlertRuleAbility.mockReturnValue([true, true]);
mockCombinedRule.mockReturnValue({
result: mockGrafanaRule as CombinedRule,
loading: false,
dispatched: true,
requestId: 'A',
error: undefined,
});
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((action) => action === AccessControlAction.AlertingInstanceCreate);
// Act
await renderRuleViewer('test1');
// Assert
await waitFor(() => {
expect(ui.actionButtons.silence.get()).toBeInTheDocument();
});
});
it('Should render clone button for users having create rule permission', async () => {
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Duplicate ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({
result: getGrafanaRule({ name: 'Grafana rule' }),
loading: false,
dispatched: true,
});
grantUserPermissions([AccessControlAction.AlertingRuleCreate]);
await renderRuleViewer('test1');
await user.click(ui.moreButton.get());
expect(ui.moreButtons.duplicate.get()).toBeInTheDocument();
});
it('Should NOT render clone button for users without create rule permission', async () => {
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Duplicate ? [true, false] : [true, true];
});
mockCombinedRule.mockReturnValue({
result: getGrafanaRule({ name: 'Grafana rule' }),
loading: false,
dispatched: true,
});
const { AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete } = AccessControlAction;
grantUserPermissions([AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete]);
await renderRuleViewer('test1');
await user.click(ui.moreButton.get());
expect(ui.moreButtons.duplicate.query()).not.toBeInTheDocument();
});
});
describe('Cloud rules action buttons', () => {
const mockCombinedRule = jest.fn();
afterEach(() => {
mockCombinedRule.mockReset();
});
it('Should render edit button for users with the update permission', async () => {
// Arrange
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({
result: mockCloudRule as CombinedRule,
loading: false,
dispatched: true,
requestId: 'A',
error: undefined,
});
// Act
await renderRuleViewer('test1');
// Assert
expect(ui.actionButtons.edit.query()).toBeInTheDocument();
});
it('Should render Delete button for users with the delete permission', async () => {
// Arrange
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({
result: mockCloudRule as CombinedRule,
loading: false,
dispatched: true,
requestId: 'A',
error: undefined,
});
// Act
await renderRuleViewer('test1');
await user.click(ui.moreButton.get());
// Assert
expect(ui.moreButtons.delete.query()).toBeInTheDocument();
});
});
});

View File

@ -1,262 +0,0 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import {
Alert,
Button,
Collapse,
Icon,
IconButton,
LoadingPlaceholder,
useStyles2,
VerticalGroup,
Stack,
Text,
} from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
import { GrafanaRuleDefinition } from '../../../../../types/unified-alerting-dto';
import { useCombinedRule } from '../../hooks/useCombinedRule';
import { useCleanAnnotations } from '../../utils/annotations';
import { getRulesSourceByName } from '../../utils/datasource';
import * as ruleId from '../../utils/rule-id';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels';
import { DetailsField } from '../DetailsField';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { decodeGrafanaNamespace } from '../expressions/util';
import { RuleViewerLayout } from '../rule-viewer/RuleViewerLayout';
import { RuleDetailsActionButtons } from '../rules/RuleDetailsActionButtons';
import { RuleDetailsAnnotations } from '../rules/RuleDetailsAnnotations';
import { RuleDetailsDataSources } from '../rules/RuleDetailsDataSources';
import { RuleDetailsExpression } from '../rules/RuleDetailsExpression';
import { RuleDetailsFederatedSources } from '../rules/RuleDetailsFederatedSources';
import { RuleDetailsMatchingInstances } from '../rules/RuleDetailsMatchingInstances';
import { RuleHealth } from '../rules/RuleHealth';
import { RuleState } from '../rules/RuleState';
import { QueryResults } from './tabs/Query';
type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>;
const errorMessage = 'Could not find data source for rule';
const errorTitle = 'Could not view rule';
const pageTitle = 'View rule';
export function RuleViewer({ match }: RuleViewerProps) {
const styles = useStyles2(getStyles);
const [expandQuery, setExpandQuery] = useToggle(false);
const identifier = useMemo(() => {
const id = ruleId.getRuleIdFromPathname(match.params);
if (!id) {
throw new Error('Rule ID is required');
}
return ruleId.parse(id, true);
}, [match.params]);
const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
const annotations = useCleanAnnotations(rule?.annotations || {});
if (!identifier?.ruleSourceName) {
return (
<RuleViewerLayout title={pageTitle}>
<Alert title={errorTitle}>
<details className={styles.errorMessage}>{errorMessage}</details>
</Alert>
</RuleViewerLayout>
);
}
const rulesSource = getRulesSourceByName(identifier.ruleSourceName);
if (loading) {
return (
<RuleViewerLayout title={pageTitle}>
<LoadingPlaceholder text="Loading rule..." />
</RuleViewerLayout>
);
}
if (error || !rulesSource) {
return (
<Alert title={errorTitle}>
<details className={styles.errorMessage}>
{isFetchError(error) ? error.message : errorMessage}
<br />
{/* TODO Fix typescript */}
{/* {error && error?.stack} */}
</details>
</Alert>
);
}
if (!rule) {
return (
<RuleViewerLayout title={pageTitle}>
<span>Rule could not be found.</span>
</RuleViewerLayout>
);
}
const isFederatedRule = isFederatedRuleGroup(rule.group);
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
return (
<RuleViewerLayout
wrapInContent={false}
title={pageTitle}
renderTitle={() => (
<Stack direction="row" alignItems="flex-start" gap={1}>
<Icon name="bell" size="xl" />
<Text variant="h3">{rule.name}</Text>
<RuleState rule={rule} isCreating={false} isDeleting={false} />
</Stack>
)}
>
{isFederatedRule && (
<Alert severity="info" title="This rule is part of a federated rule group.">
<VerticalGroup>
Federated rule groups are currently an experimental feature.
<Button fill="text" icon="book">
<a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation">
Read documentation
</a>
</Button>
</VerticalGroup>
</Alert>
)}
{isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />}
<>
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={true} />
<div className={styles.details}>
<div className={styles.leftSide}>
{rule.promRule && (
<DetailsField label="Health" horizontal={true}>
<RuleHealth rule={rule.promRule} />
</DetailsField>
)}
{!!rule.labels && !!Object.keys(rule.labels).length && (
<DetailsField label="Labels" horizontal={true}>
<AlertLabels labels={rule.labels} />
</DetailsField>
)}
<RuleDetailsExpression rulesSource={rulesSource} rule={rule} annotations={annotations} />
<RuleDetailsAnnotations annotations={annotations} />
</div>
<div className={styles.rightSide}>
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
{decodeGrafanaNamespace(rule.namespace).name} / {rule.group.name}
</DetailsField>
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
</div>
</div>
<div>
<DetailsField label="Matching instances" horizontal={true}>
<RuleDetailsMatchingInstances
rule={rule}
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
enableFiltering
/>
</DetailsField>
</div>
</>
<Collapse
label="Query & Results"
isOpen={expandQuery}
onToggle={setExpandQuery}
collapsible={true}
className={styles.collapse}
>
{expandQuery && <QueryResults rule={rule} />}
</Collapse>
</RuleViewerLayout>
);
}
function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) {
const styles = useStyles2(getStyles);
const copyUID = () => navigator.clipboard && navigator.clipboard.writeText(rule.uid);
return (
<DetailsField label="Rule UID" childrenWrapperClassName={styles.ruleUid}>
{rule.uid} <IconButton name="copy" onClick={copyUID} tooltip="Copy rule UID" />
</DetailsField>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
errorMessage: css`
white-space: pre-wrap;
`,
queries: css`
height: 100%;
width: 100%;
`,
collapse: css`
margin-top: ${theme.spacing(2)};
border-color: ${theme.colors.border.weak};
border-radius: ${theme.shape.radius.default};
`,
queriesTitle: css`
padding: ${theme.spacing(2, 0.5)};
font-size: ${theme.typography.h5.fontSize};
font-weight: ${theme.typography.fontWeightBold};
font-family: ${theme.typography.h5.fontFamily};
`,
query: css`
border-bottom: 1px solid ${theme.colors.border.medium};
padding: ${theme.spacing(2)};
`,
queryWarning: css`
margin: ${theme.spacing(4, 0)};
`,
title: css`
font-size: ${theme.typography.h4.fontSize};
font-weight: ${theme.typography.fontWeightBold};
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`,
details: css`
display: flex;
flex-direction: row;
gap: ${theme.spacing(4)};
`,
leftSide: css`
flex: 1;
overflow: hidden;
`,
rightSide: css`
padding-right: ${theme.spacing(3)};
max-width: 360px;
word-break: break-all;
overflow: hidden;
`,
rightSideDetails: css`
& > div:first-child {
width: auto;
}
`,
labels: css`
justify-content: flex-start;
`,
ruleUid: css`
display: flex;
align-items: center;
gap: ${theme.spacing(1)};
`,
};
};
export default RuleViewer;

View File

@ -4,9 +4,9 @@ import { Stack, Text } from '@grafana/ui';
import { RuleHealth } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { AlertStateDot } from '../../AlertStateDot';
import { AlertStateDot } from '../AlertStateDot';
import { isErrorHealth } from './RuleViewer.v2';
import { isErrorHealth } from './RuleViewer';
interface RecordingBadgeProps {
health?: RuleHealth;

View File

@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { Rule } from 'app/types/unified-alerting';
import { isErrorHealth } from '../rule-viewer/v2/RuleViewer.v2';
import { isErrorHealth } from '../rule-viewer/RuleViewer';
interface Prom {
rule: Rule;