From c9e28044f158fdd01457fe9da515dffff1403790 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Thu, 1 Jul 2021 12:02:41 +0200 Subject: [PATCH] Alerting: view to display alert rule and its underlying data. (#35546) * add page and basic things * quick annotations * added so we can run queries on the view rule page. * wip. * merge * cleaned up the combined rule hook. * readd queries * fixing so you can run queries. * renamed variable. * fix rerenders and visualizing * minor fixes. * work in progress. * wip * a working version that can be tested. * changing check if we have data. * removed unused styling. * removed unused dep. * removed another dep. * Update public/app/features/alerting/unified/hooks/useCombinedRule.ts Co-authored-by: Domas * Update public/app/features/alerting/unified/hooks/useCombinedRule.ts Co-authored-by: Domas * refactored and changed UI according to figma. * resseting menu item. * removing unused external link. * refactor according to feedback. * changed so we always fetch the rule. * fixing so datasource only is displayed once. Also changed so we only navigate to alert list when rule has been deleted. * removed unused dep. * Will display query as json if we can't find data source. * changed to a function instead of the React.FC. * refactoring of id generation and added support to generate ids for native prometheus alerts without ruler. * set max width on page content * added page where you can easily link to a rule in grafana. * listing rules with same name. * fixing error cases. * updates after pr feedback * more pr feedback * use 1h-now as timerange * remove unused import * start on test * add test for cloud case * add ruleview render test * add render tests for grafana and cloud alerts * add mock for backendsrv * add rendering test for the find route * check if cards are rendered Co-authored-by: Peter Holmberg Co-authored-by: Domas --- .../unified/RedirectToRuleViewer.test.tsx | 143 ++++++++++++ .../alerting/unified/RedirectToRuleViewer.tsx | 111 +++++++++ .../features/alerting/unified/RuleEditor.tsx | 13 +- .../alerting/unified/RuleViewer.test.tsx | 138 ++++++++++++ .../features/alerting/unified/RuleViewer.tsx | 195 ++++++++++++++++ .../components/PanelPluginsButtonGroup.tsx | 44 ++++ .../components/rule-editor/QueryWrapper.tsx | 3 +- .../components/rule-editor/VizWrapper.tsx | 20 +- .../rule-viewer/RuleViewerLayout.tsx | 54 +++++ .../rule-viewer/RuleViewerVisualization.tsx | 143 ++++++++++++ .../unified/components/rules/RuleDetails.tsx | 81 +------ .../rules/RuleDetailsActionButtons.tsx | 57 +++-- .../rules/RuleDetailsAnnotations.tsx | 31 +++ .../rules/RuleDetailsDataSources.tsx | 74 ++++++ .../rules/RuleDetailsExpression.tsx | 33 +++ .../rules/RuleDetailsMatchingInstances.tsx | 23 ++ .../alerting/unified/hooks/useCombinedRule.ts | 116 ++++++++++ .../hooks/useCombinedRuleNamespaces.ts | 2 +- .../alerting/unified/state/actions.ts | 80 ++++--- .../alerting/unified/state/reducers.ts | 4 +- .../alerting/unified/utils/datasource.ts | 4 +- .../features/alerting/unified/utils/misc.ts | 27 +-- .../alerting/unified/utils/query.test.ts | 118 ++++++++++ .../features/alerting/unified/utils/query.ts | 67 ++++++ .../alerting/unified/utils/rule-id.ts | 213 ++++++++++++++++++ .../features/alerting/unified/utils/rules.ts | 101 ++------- public/app/routes/routes.tsx | 15 ++ public/app/types/unified-alerting.ts | 23 +- 28 files changed, 1671 insertions(+), 262 deletions(-) create mode 100644 public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx create mode 100644 public/app/features/alerting/unified/RedirectToRuleViewer.tsx create mode 100644 public/app/features/alerting/unified/RuleViewer.test.tsx create mode 100644 public/app/features/alerting/unified/RuleViewer.tsx create mode 100644 public/app/features/alerting/unified/components/PanelPluginsButtonGroup.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx create mode 100644 public/app/features/alerting/unified/hooks/useCombinedRule.ts create mode 100644 public/app/features/alerting/unified/utils/query.test.ts create mode 100644 public/app/features/alerting/unified/utils/query.ts create mode 100644 public/app/features/alerting/unified/utils/rule-id.ts diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx new file mode 100644 index 00000000000..5545495b87f --- /dev/null +++ b/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { DataSourceJsonData, PluginMeta } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { RedirectToRuleViewer } from './RedirectToRuleViewer'; +import { configureStore } from 'app/store/configureStore'; +import { typeAsJestMock } from '../../../../test/helpers/typeAsJestMock'; +import { useCombinedRulesMatching } from './hooks/useCombinedRule'; +import { CombinedRule, Rule } from '../../../types/unified-alerting'; +import { PromRuleType } from '../../../types/unified-alerting-dto'; +import { getRulesSourceByName } from './utils/datasource'; + +jest.mock('./hooks/useCombinedRule'); +jest.mock('./utils/datasource'); +jest.mock('react-router-dom', () => ({ + ...(jest.requireActual('react-router-dom') as any), + Redirect: jest.fn(({}) => `Redirected`), +})); + +const store = configureStore(); +const renderRedirectToRuleViewer = () => { + return render( + + + + + + ); +}; + +const mockRuleSourceByName = () => { + typeAsJestMock(getRulesSourceByName).mockReturnValue({ + name: 'prom test', + type: 'prometheus', + uid: 'asdf23', + id: 1, + meta: {} as PluginMeta, + jsonData: {} as DataSourceJsonData, + }); +}; + +describe('Redirect to Rule viewer', () => { + it('should list rules that match the same name', () => { + typeAsJestMock(useCombinedRulesMatching).mockReturnValue({ + result: mockedRules, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + mockRuleSourceByName(); + renderRedirectToRuleViewer(); + expect(screen.getAllByText('Cloud test alert')).toHaveLength(2); + }); + + it('should redirect to view rule page if only one match', () => { + typeAsJestMock(useCombinedRulesMatching).mockReturnValue({ + result: [mockedRules[0]], + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + mockRuleSourceByName(); + renderRedirectToRuleViewer(); + expect(screen.getByText('Redirected')).toBeInTheDocument(); + }); +}); + +const mockedRules: CombinedRule[] = [ + { + name: 'Cloud test alert', + labels: {}, + query: 'up == 0', + annotations: {}, + group: { + name: 'test', + rules: [], + }, + promRule: { + health: 'ok', + name: 'cloud up alert', + query: 'up == 0', + type: PromRuleType.Alerting, + } as Rule, + namespace: { + name: 'prom test alerts', + groups: [], + rulesSource: { + name: 'prom test', + type: 'prometheus', + uid: 'asdf23', + id: 1, + meta: {} as PluginMeta, + jsonData: {} as DataSourceJsonData, + }, + }, + }, + { + name: 'Cloud test alert', + labels: {}, + query: 'up == 0', + annotations: {}, + group: { + name: 'test', + rules: [], + }, + promRule: { + health: 'ok', + name: 'cloud up alert', + query: 'up == 0', + type: PromRuleType.Alerting, + } as Rule, + namespace: { + name: 'prom test alerts', + groups: [], + rulesSource: { + name: 'prom test', + type: 'prometheus', + uid: 'asdf23', + id: 1, + meta: {} as PluginMeta, + jsonData: {} as DataSourceJsonData, + }, + }, + }, +]; + +const mockRoute = (ruleName: string, sourceName: string) => { + return { + route: { + path: '/', + component: RedirectToRuleViewer, + }, + queryParams: { returnTo: '/alerting/list' }, + match: { params: { name: ruleName, sourceName: sourceName }, isExact: false, url: 'asdf', path: '' }, + history: locationService.getHistory(), + location: { pathname: '', hash: '', search: '', state: '' }, + staticContext: {}, + }; +}; diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx new file mode 100644 index 00000000000..5fe1c32b0da --- /dev/null +++ b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, Card, Icon, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { useCombinedRulesMatching } from './hooks/useCombinedRule'; +import { createViewLink } from './utils/misc'; +import { getRulesSourceByName } from './utils/datasource'; +import { RuleViewerLayout } from './components/rule-viewer/RuleViewerLayout'; +import { AlertLabels } from './components/AlertLabels'; + +type RedirectToRuleViewerProps = GrafanaRouteComponentProps<{ name?: string; sourceName?: string }>; +const pageTitle = 'Alerting / Find rule'; + +export function RedirectToRuleViewer(props: RedirectToRuleViewerProps): JSX.Element | null { + const { name, sourceName } = props.match.params; + const styles = useStyles2(getStyles); + const { error, loading, result: rules, dispatched } = useCombinedRulesMatching(name, sourceName); + + if (error) { + return ( + + +
+ {error.message} +
+ {!!error?.stack && error.stack} +
+
+
+ ); + } + + if (loading || !dispatched || !Array.isArray(rules)) { + return ( + + + + ); + } + + if (!name || !sourceName) { + return ; + } + + const rulesSource = getRulesSourceByName(sourceName); + + if (!rulesSource) { + return ( + + +
{`Could not find data source with name: ${sourceName}.`}
+
+
+ ); + } + + if (rules.length === 1) { + const [rule] = rules; + return ; + } + + return ( + +
+ Several rules in {sourceName} matched the name{' '} + {name}, please select the rule you want to view. +
+
+ {rules.map((rule, index) => { + return ( + + + + {`${rule.namespace.name} / ${rule.group.name}`} + + + + + + ); + })} +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + param: css` + font-style: italic; + color: ${theme.colors.text.secondary}; + `, + rules: css` + margin-top: ${theme.spacing(2)}; + `, + namespace: css` + margin-left: ${theme.spacing(1)}; + `, + errorMessage: css` + white-space: pre-wrap; + `, + }; +} + +export default withErrorBoundary(RedirectToRuleViewer, { style: 'page' }); diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index 65c5b3cf9a5..f4c2e445cac 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -11,8 +11,8 @@ import { useDispatch } from 'react-redux'; import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; import { useIsRuleEditable } from './hooks/useIsRuleEditable'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchExistingRuleAction } from './state/actions'; -import { parseRuleIdentifier } from './utils/rules'; +import { fetchEditableRuleAction } from './state/actions'; +import * as ruleId from './utils/rule-id'; interface ExistingRuleEditorProps { identifier: RuleIdentifier; @@ -26,7 +26,7 @@ const ExistingRuleEditor: FC = ({ identifier }) => { useEffect(() => { if (!dispatched) { - dispatch(fetchExistingRuleAction(identifier)); + dispatch(fetchEditableRuleAction(identifier)); } }, [dispatched, dispatch, identifier]); @@ -58,9 +58,10 @@ const ExistingRuleEditor: FC = ({ identifier }) => { type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>; const RuleEditor: FC = ({ match }) => { - const id = match.params.id; - if (id) { - const identifier = parseRuleIdentifier(decodeURIComponent(id)); + const { id } = match.params; + const identifier = ruleId.tryParse(id, true); + + if (identifier) { return ; } if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) { diff --git a/public/app/features/alerting/unified/RuleViewer.test.tsx b/public/app/features/alerting/unified/RuleViewer.test.tsx new file mode 100644 index 00000000000..a84471aba02 --- /dev/null +++ b/public/app/features/alerting/unified/RuleViewer.test.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { DataSourceJsonData, PluginMeta } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { RuleViewer } from './RuleViewer'; +import { configureStore } from 'app/store/configureStore'; +import { typeAsJestMock } from '../../../../test/helpers/typeAsJestMock'; +import { useCombinedRule } from './hooks/useCombinedRule'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; + +jest.mock('./hooks/useCombinedRule'); +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as any), + getDataSourceSrv: () => { + return { + getInstanceSettings: () => ({ name: 'prometheus' }), + }; + }, +})); + +const store = configureStore(); +const renderRuleViewer = () => { + return render( + + + + + + ); +}; +describe('RuleViewer', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should render page with grafana alert', () => { + typeAsJestMock(useCombinedRule).mockReturnValue({ + result: mockGrafanaRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + + renderRuleViewer(); + expect(screen.getByText('Alerting / View rule')).toBeInTheDocument(); + expect(screen.getByText('Test alert')).toBeInTheDocument(); + }); + + it('should render page with cloud alert', () => { + typeAsJestMock(useCombinedRule).mockReturnValue({ + result: mockCloudRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + renderRuleViewer(); + expect(screen.getByText('Alerting / View rule')).toBeInTheDocument(); + expect(screen.getByText('Cloud test alert')).toBeInTheDocument(); + }); +}); + +const mockGrafanaRule = { + name: 'Test alert', + query: 'up', + labels: {}, + annotations: {}, + group: { + name: 'Prom up alert', + rules: [], + }, + namespace: { + rulesSource: GRAFANA_RULES_SOURCE_NAME, + name: 'Alerts', + groups: [], + }, + rulerRule: { + for: '', + annotations: {}, + labels: {}, + grafana_alert: { + condition: 'B', + exec_err_state: GrafanaAlertStateDecision.Alerting, + namespace_uid: 'namespaceuid123', + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'Test alert', + uid: 'asdf23', + data: [], + }, + }, +}; + +const mockCloudRule = { + name: 'Cloud test alert', + labels: {}, + query: 'up == 0', + annotations: {}, + group: { + name: 'test', + rules: [], + }, + promRule: { + health: 'ok', + name: 'cloud up alert', + query: 'up == 0', + type: 'alerting', + }, + namespace: { + name: 'prom test alerts', + groups: [], + rulesSource: { + name: 'prom test', + type: 'prometheus', + uid: 'asdf23', + id: 1, + meta: {} as PluginMeta, + jsonData: {} as DataSourceJsonData, + }, + }, +}; + +const mockRoute: GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> = { + route: { + path: '/', + component: RuleViewer, + }, + queryParams: { returnTo: '/alerting/list' }, + match: { params: { id: 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' }, + history: locationService.getHistory(), + location: { pathname: '', hash: '', search: '', state: '' }, + staticContext: {}, +}; diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx new file mode 100644 index 00000000000..df7d0095513 --- /dev/null +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -0,0 +1,195 @@ +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useObservable } from 'react-use'; +import { css } from '@emotion/css'; +import { GrafanaTheme2, LoadingState, PanelData } from '@grafana/data'; +import { + withErrorBoundary, + useStyles2, + Alert, + LoadingPlaceholder, + PanelChromeLoadingIndicator, + Icon, +} from '@grafana/ui'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { AlertingQueryRunner } from './state/AlertingQueryRunner'; +import { useCombinedRule } from './hooks/useCombinedRule'; +import { alertRuleToQueries } from './utils/query'; +import { RuleState } from './components/rules/RuleState'; +import { getRulesSourceByName } from './utils/datasource'; +import { DetailsField } from './components/DetailsField'; +import { RuleHealth } from './components/rules/RuleHealth'; +import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization'; +import { RuleDetailsActionButtons } from './components/rules/RuleDetailsActionButtons'; +import { RuleDetailsMatchingInstances } from './components/rules/RuleDetailsMatchingInstances'; +import { RuleDetailsDataSources } from './components/rules/RuleDetailsDataSources'; +import { RuleViewerLayout, RuleViewerLayoutContent } from './components/rule-viewer/RuleViewerLayout'; +import { AlertLabels } from './components/AlertLabels'; +import { RuleDetailsExpression } from './components/rules/RuleDetailsExpression'; +import { RuleDetailsAnnotations } from './components/rules/RuleDetailsAnnotations'; +import * as ruleId from './utils/rule-id'; + +type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>; + +const errorMessage = 'Could not find data source for rule'; +const errorTitle = 'Could not view rule'; +const pageTitle = 'Alerting / View rule'; + +export function RuleViewer({ match }: RuleViewerProps) { + const styles = useStyles2(getStyles); + const { id, sourceName } = match.params; + const identifier = ruleId.tryParse(id, true); + const { loading, error, result: rule } = useCombinedRule(identifier, sourceName); + const runner = useMemo(() => new AlertingQueryRunner(), []); + const data = useObservable(runner.get()); + const queries = useMemo(() => alertRuleToQueries(rule), [rule]); + + const onRunQueries = useCallback(() => { + if (queries.length > 0) { + runner.run(queries); + } + }, [queries, runner]); + + useEffect(() => { + onRunQueries(); + }, [onRunQueries]); + + useEffect(() => { + return () => runner.destroy(); + }, [runner]); + + if (!sourceName) { + return ( + + +
{errorMessage}
+
+
+ ); + } + + const rulesSource = getRulesSourceByName(sourceName); + + if (loading) { + return ( + + + + ); + } + + if (error || !rulesSource) { + return ( + + +
+ {error?.message ?? errorMessage} +
+ {!!error?.stack && error.stack} +
+
+
+ ); + } + + if (!rule) { + return ( + + Rule could not be found. + + ); + } + const annotations = Object.entries(rule.annotations).filter(([_, value]) => !!value.trim()); + return ( + + +
+

+ {rule.name} +

+ + +
+
+
+ {rule.promRule && ( + + + + )} + {!!rule.labels && !!Object.keys(rule.labels).length && ( + + + + )} + + +
+
+ + {`${rule.namespace.name} / ${rule.group.name}`} +
+
+
+ +
+
+ {data && Object.keys(data).length > 0 && ( + <> +
+ Query results runner.cancel()} /> +
+ +
+ {queries.map((query) => { + return ( +
+ +
+ ); + })} +
+
+ + )} +
+ ); +} + +function isLoading(data: Record): boolean { + return !!Object.values(data).find((d) => d.state === LoadingState.Loading); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + errorMessage: css` + white-space: pre-wrap; + `, + queries: css` + height: 100%; + width: 100%; + `, + 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)}; + `, + details: css` + display: flex; + flex-direction: row; + `, + leftSide: css` + flex: 1; + `, + rightSide: css` + padding-left: 90px; + width: 300px; + `, + }; +}; + +export default withErrorBoundary(RuleViewer, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/PanelPluginsButtonGroup.tsx b/public/app/features/alerting/unified/components/PanelPluginsButtonGroup.tsx new file mode 100644 index 00000000000..0f8b9b5486b --- /dev/null +++ b/public/app/features/alerting/unified/components/PanelPluginsButtonGroup.tsx @@ -0,0 +1,44 @@ +import { SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { RadioButtonGroup } from '@grafana/ui'; +import React, { useMemo } from 'react'; +import { STAT, TABLE, TIMESERIES } from '../utils/constants'; + +export type SupportedPanelPlugins = 'timeseries' | 'table' | 'stat'; + +type Props = { + value: SupportedPanelPlugins; + onChange: (value: SupportedPanelPlugins) => void; + size?: 'sm' | 'md'; +}; + +export function PanelPluginsButtonGroup(props: Props): JSX.Element | null { + const { value, onChange, size = 'md' } = props; + const panels = useMemo(() => getSupportedPanels(), []); + + return ; +} + +function getSupportedPanels(): Array> { + return Object.values(config.panels).reduce((panels: Array>, panel) => { + if (isSupportedPanelPlugin(panel.id)) { + panels.push({ + value: panel.id, + label: panel.name, + imgUrl: panel.info.logos.small, + }); + } + return panels; + }, []); +} + +function isSupportedPanelPlugin(id: string): id is SupportedPanelPlugins { + switch (id) { + case TIMESERIES: + case TABLE: + case STAT: + return true; + default: + return false; + } +} diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index 5ec80a68442..15728e61411 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -15,6 +15,7 @@ import { VizWrapper } from './VizWrapper'; import { isExpressionQuery } from 'app/features/expressions/guards'; import { TABLE, TIMESERIES } from '../../utils/constants'; import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; interface Props { data: PanelData; @@ -30,8 +31,6 @@ interface Props { index: number; } -export type SupportedPanelPlugins = 'timeseries' | 'table' | 'stat'; - export const QueryWrapper: FC = ({ data, dsSettings, diff --git a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx index a50122ce8f8..eb077568766 100644 --- a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx @@ -2,12 +2,11 @@ import React, { FC, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { css } from '@emotion/css'; import { GrafanaTheme2, PanelData } from '@grafana/data'; -import { config, PanelRenderer } from '@grafana/runtime'; -import { RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { PanelRenderer } from '@grafana/runtime'; +import { useStyles2 } from '@grafana/ui'; import { PanelOptions } from 'app/plugins/panel/table/models.gen'; -import { SupportedPanelPlugins } from './QueryWrapper'; import { useVizHeight } from '../../hooks/useVizHeight'; -import { STAT, TABLE, TIMESERIES } from '../../utils/constants'; +import { SupportedPanelPlugins, PanelPluginsButtonGroup } from '../PanelPluginsButtonGroup'; interface Props { data: PanelData; @@ -20,7 +19,6 @@ export const VizWrapper: FC = ({ data, currentPanel, changePanel }) => { frameIndex: 0, showHeader: true, }); - const panels = getSupportedPanels(); const vizHeight = useVizHeight(data, currentPanel, options.frameIndex); const styles = useStyles2(getStyles(vizHeight)); @@ -31,7 +29,7 @@ export const VizWrapper: FC = ({ data, currentPanel, changePanel }) => { return (
- +
{({ width }) => { @@ -57,21 +55,11 @@ export const VizWrapper: FC = ({ data, currentPanel, changePanel }) => { ); }; -const getSupportedPanels = () => { - return Object.values(config.panels) - .filter((p) => p.id === TIMESERIES || p.id === TABLE || p.id === STAT) - .map((panel) => ({ value: panel.id, label: panel.name, imgUrl: panel.info.logos.small })); -}; - const getStyles = (visHeight: number) => (theme: GrafanaTheme2) => ({ wrapper: css` padding: 0 ${theme.spacing(2)}; height: ${visHeight + theme.spacing.gridSize * 4}px; `, - autoSizerWrapper: css` - width: 100%; - height: 200px; - `, buttonGroup: css` display: flex; justify-content: flex-end; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx new file mode 100644 index 00000000000..1dff5a1807d --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { PageToolbar, useStyles2 } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; + +type Props = { + children: React.ReactNode | React.ReactNode[]; + title: string; + wrapInContent?: boolean; +}; + +export function RuleViewerLayout(props: Props): JSX.Element | null { + const { wrapInContent = true, children, title } = props; + const styles = useStyles2(getPageStyles); + + return ( + + locationService.push('/alerting/list')} /> +
{wrapInContent ? : children}
+
+ ); +} + +type ContentProps = { + children: React.ReactNode | React.ReactNode[]; + padding?: number; +}; + +export function RuleViewerLayoutContent({ children, padding = 2 }: ContentProps): JSX.Element | null { + const styles = useStyles2(getContentStyles(padding)); + return
{children}
; +} + +const getPageStyles = (theme: GrafanaTheme2) => { + return { + content: css` + margin: ${theme.spacing(0, 2, 2)}; + max-width: ${theme.breakpoints.values.xxl}px; + `, + }; +}; + +const getContentStyles = (padding: number) => (theme: GrafanaTheme2) => { + return { + wrapper: css` + background: ${theme.colors.background.primary}; + border: 1px solid ${theme.colors.border.weak}; + border-radius: ${theme.shape.borderRadius()}; + padding: ${theme.spacing(padding)}; + `, + }; +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx new file mode 100644 index 00000000000..2db7e3c9813 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import { DataSourceInstanceSettings, GrafanaTheme2, PanelData, urlUtil } from '@grafana/data'; +import { getDataSourceSrv, PanelRenderer } from '@grafana/runtime'; +import { Alert, CodeEditor, LinkButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { isExpressionQuery } from 'app/features/expressions/guards'; +import { PanelOptions } from 'app/plugins/panel/table/models.gen'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { PanelPluginsButtonGroup, SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; +import { TABLE, TIMESERIES } from '../../utils/constants'; + +type RuleViewerVisualizationProps = { + data?: PanelData; + query: AlertQuery; +}; + +const headerHeight = 4; + +export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JSX.Element | null { + const theme = useTheme2(); + const styles = useStyles2(getStyles); + const { data, query } = props; + const defaultPanel = isExpressionQuery(query.model) ? TABLE : TIMESERIES; + const [panel, setPanel] = useState(defaultPanel); + const dsSettings = getDataSourceSrv().getInstanceSettings(query.datasourceUid); + const [options, setOptions] = useState({ + frameIndex: 0, + showHeader: true, + }); + + if (!data) { + return null; + } + + if (!dsSettings) { + return ( +
+ + +
+ ); + } + + return ( +
+ + {({ width, height }) => { + return ( +
+
+
+ {`Query ${query.refId}`} + ({dsSettings.name}) +
+
+ + {!isExpressionQuery(query.model) && ( + <> +
+ + View in Explore + + + )} +
+
+ +
+ ); + }} + +
+ ); +} + +function createExploreLink(settings: DataSourceInstanceSettings, query: AlertQuery): string { + const { name } = settings; + const { refId, ...rest } = query.model; + const queryParams = { ...rest, datasource: name }; + + return urlUtil.renderUrl('/explore', { + left: JSON.stringify(['now-1h', 'now', name, queryParams]), + }); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + content: css` + width: 100%; + height: 250px; + `, + header: css` + height: ${theme.spacing(headerHeight)}; + display: flex; + align-items: center; + justify-content: space-between; + white-space: nowrap; + `, + refId: css` + font-weight: ${theme.typography.fontWeightMedium}; + color: ${theme.colors.text.link}; + overflow: hidden; + `, + dataSource: css` + margin-left: ${theme.spacing(1)}; + font-style: italic; + color: ${theme.colors.text.secondary}; + `, + actions: css` + display: flex; + align-items: center; + `, + spacing: css` + padding: ${theme.spacing(0, 1, 0, 0)}; + `, + errorMessage: css` + white-space: pre-wrap; + `, + }; +}; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index dd5e9248d91..af21b4d4ab2 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -1,18 +1,15 @@ import { CombinedRule } from 'app/types/unified-alerting'; -import React, { FC, useMemo } from 'react'; +import React, { FC } from 'react'; import { useStyles2 } from '@grafana/ui'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; -import { isCloudRulesSource } from '../../utils/datasource'; -import { AnnotationDetailsField } from '../AnnotationDetailsField'; import { AlertLabels } from '../AlertLabels'; -import { AlertInstancesTable } from './AlertInstancesTable'; import { DetailsField } from '../DetailsField'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource'; -import { Expression } from '../Expression'; import { RuleDetailsActionButtons } from './RuleDetailsActionButtons'; +import { RuleDetailsDataSources } from './RuleDetailsDataSources'; +import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances'; +import { RuleDetailsExpression } from './RuleDetailsExpression'; +import { RuleDetailsAnnotations } from './RuleDetailsAnnotations'; interface Props { rule: CombinedRule; @@ -20,7 +17,6 @@ interface Props { export const RuleDetails: FC = ({ rule }) => { const styles = useStyles2(getStyles); - const { promRule, namespace: { rulesSource }, @@ -28,29 +24,6 @@ export const RuleDetails: FC = ({ rule }) => { const annotations = Object.entries(rule.annotations).filter(([_, value]) => !!value.trim()); - const dataSources: Array<{ name: string; icon?: string }> = useMemo(() => { - if (isCloudRulesSource(rulesSource)) { - return [{ name: rulesSource.name, icon: rulesSource.meta.info.logos.small }]; - } - - if (isGrafanaRulerRule(rule.rulerRule)) { - const { data } = rule.rulerRule.grafana_alert; - - return data.reduce((dataSources, query) => { - const ds = getDatasourceSrv().getInstanceSettings(query.datasourceUid); - - if (!ds || ds.uid === ExpressionDatasourceUID) { - return dataSources; - } - - dataSources.push({ name: ds.name, icon: ds.meta.info.logos.small }); - return dataSources; - }, [] as Array<{ name: string; icon?: string }>); - } - - return []; - }, [rule, rulesSource]); - return (
@@ -61,41 +34,14 @@ export const RuleDetails: FC = ({ rule }) => { )} - {isCloudRulesSource(rulesSource) && ( - - - - )} - {annotations.map(([key, value]) => ( - - ))} + +
- {!!dataSources.length && ( - - {dataSources.map(({ name, icon }) => ( -
- {icon && ( - <> - {' '} - - )} - {name} -
- ))} -
- )} +
- {promRule && isAlertingRule(promRule) && !!promRule.alerts?.length && ( - - - - )} +
); }; @@ -117,11 +63,4 @@ export const getStyles = (theme: GrafanaTheme2) => ({ width: 300px; } `, - exprRow: css` - margin-bottom: 46px; - `, - dataSourceIcon: css` - width: ${theme.spacing(2)}; - height: ${theme.spacing(2)}; - `, }); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 5408c2ec715..c8a8533c999 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -10,8 +10,8 @@ import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { deleteRuleAction } from '../../state/actions'; import { Annotation } from '../../utils/constants'; import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; -import { createExploreLink } from '../../utils/misc'; -import { getRuleIdentifier, stringifyRuleIdentifier } from '../../utils/rules'; +import { createExploreLink, createViewLink } from '../../utils/misc'; +import * as ruleId from '../../utils/rule-id'; interface Props { rule: CombinedRule; @@ -29,19 +29,19 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { const rightButtons: JSX.Element[] = []; const { isEditable } = useIsRuleEditable(rulerRule); + const returnTo = location.pathname + location.search; + const isViewMode = inViewMode(location.pathname); const deleteRule = () => { if (ruleToDelete && ruleToDelete.rulerRule) { - dispatch( - deleteRuleAction( - getRuleIdentifier( - getRulesSourceName(ruleToDelete.namespace.rulesSource), - ruleToDelete.namespace.name, - ruleToDelete.group.name, - ruleToDelete.rulerRule - ) - ) + const identifier = ruleId.fromRulerRule( + getRulesSourceName(ruleToDelete.namespace.rulesSource), + ruleToDelete.namespace.name, + ruleToDelete.group.name, + ruleToDelete.rulerRule ); + + dispatch(deleteRuleAction(identifier, { navigateTo: isViewMode ? '/alerting/list' : undefined })); setRuleToDelete(undefined); } }; @@ -112,17 +112,28 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { } } - if (isEditable && rulerRule) { - const editURL = urlUtil.renderUrl( - `/alerting/${encodeURIComponent( - stringifyRuleIdentifier( - getRuleIdentifier(getRulesSourceName(rulesSource), namespace.name, group.name, rulerRule) - ) - )}/edit`, - { - returnTo: location.pathname + location.search, - } + if (!isViewMode) { + rightButtons.push( + + View + ); + } + + if (isEditable && rulerRule) { + const sourceName = getRulesSourceName(rulesSource); + const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); + + const editURL = urlUtil.renderUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, { + returnTo, + }); rightButtons.push( @@ -166,6 +177,10 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { return null; }; +function inViewMode(pathname: string): boolean { + return pathname.endsWith('/view'); +} + export const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` padding: ${theme.spacing(2)} 0; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx new file mode 100644 index 00000000000..3963d88cc51 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { useStyles2 } from '@grafana/ui'; +import { AnnotationDetailsField } from '../AnnotationDetailsField'; + +type Props = { + annotations: Array<[string, string]>; +}; + +export function RuleDetailsAnnotations(props: Props): JSX.Element | null { + const { annotations } = props; + const styles = useStyles2(getStyles); + + if (annotations.length === 0) { + return null; + } + + return ( +
+ {annotations.map(([key, value]) => ( + + ))} +
+ ); +} + +const getStyles = () => ({ + annotations: css` + margin-top: 46px; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx new file mode 100644 index 00000000000..54a5e1cb49a --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx @@ -0,0 +1,74 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { useStyles2 } from '@grafana/ui'; +import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource'; +import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import React, { useMemo } from 'react'; +import { isCloudRulesSource } from '../../utils/datasource'; +import { isGrafanaRulerRule } from '../../utils/rules'; +import { DetailsField } from '../DetailsField'; + +type Props = { + rule: CombinedRule; + rulesSource: RulesSource; +}; + +export function RuleDetailsDataSources(props: Props): JSX.Element | null { + const { rulesSource, rule } = props; + const styles = useStyles2(getStyles); + + const dataSources: Array<{ name: string; icon?: string }> = useMemo(() => { + if (isCloudRulesSource(rulesSource)) { + return [{ name: rulesSource.name, icon: rulesSource.meta.info.logos.small }]; + } + + if (isGrafanaRulerRule(rule.rulerRule)) { + const { data } = rule.rulerRule.grafana_alert; + const unique = data.reduce((dataSources, query) => { + const ds = getDataSourceSrv().getInstanceSettings(query.datasourceUid); + + if (!ds || ds.uid === ExpressionDatasourceUID) { + return dataSources; + } + + dataSources[ds.name] = { name: ds.name, icon: ds.meta.info.logos.small }; + return dataSources; + }, {} as Record); + + return Object.values(unique); + } + + return []; + }, [rule, rulesSource]); + + if (dataSources.length === 0) { + return null; + } + + return ( + + {dataSources.map(({ name, icon }, index) => ( +
+ {icon && ( + <> + {' '} + + )} + {name} +
+ ))} +
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + const size = theme.spacing(2); + + return { + dataSourceIcon: css` + width: ${size}; + height: ${size}; + `, + }; +} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx new file mode 100644 index 00000000000..af3750a7bed --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { css, cx } from '@emotion/css'; +import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import { isCloudRulesSource } from '../../utils/datasource'; +import { DetailsField } from '../DetailsField'; +import { Expression } from '../Expression'; + +type Props = { + rule: CombinedRule; + rulesSource: RulesSource; + annotations: Array<[string, string]>; +}; + +export function RuleDetailsExpression(props: Props): JSX.Element | null { + const { annotations, rulesSource, rule } = props; + const styles = getStyles(); + + if (!isCloudRulesSource(rulesSource)) { + return null; + } + + return ( + + + + ); +} + +const getStyles = () => ({ + exprRow: css` + margin-bottom: 46px; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx new file mode 100644 index 00000000000..5e864813baf --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -0,0 +1,23 @@ +import { Rule } from 'app/types/unified-alerting'; +import React from 'react'; +import { isAlertingRule } from '../../utils/rules'; +import { DetailsField } from '../DetailsField'; +import { AlertInstancesTable } from './AlertInstancesTable'; + +type Props = { + promRule?: Rule; +}; + +export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { + const { promRule } = props; + + if (!isAlertingRule(promRule) || !promRule.alerts?.length) { + return null; + } + + return ( + + + + ); +} diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts new file mode 100644 index 00000000000..81e60aacb4e --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -0,0 +1,116 @@ +import { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { CombinedRule, RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting'; +import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux'; +import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; +import { fetchPromRulesAction, fetchRulerRulesAction } from '../state/actions'; +import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import * as ruleId from '../utils/rule-id'; +import { isRulerNotSupportedResponse } from '../utils/rules'; + +export function useCombinedRule( + identifier: RuleIdentifier | undefined, + ruleSourceName: string | undefined +): AsyncRequestState { + const requestState = useCombinedRulesLoader(ruleSourceName); + const combinedRules = useCombinedRuleNamespaces(ruleSourceName); + + const rule = useMemo(() => { + if (!identifier || !ruleSourceName || combinedRules.length === 0) { + return; + } + + for (const namespace of combinedRules) { + for (const group of namespace.groups) { + for (const rule of group.rules) { + const id = ruleId.fromCombinedRule(ruleSourceName, rule); + + if (ruleId.equal(id, identifier)) { + return rule; + } + } + } + } + + return; + }, [identifier, ruleSourceName, combinedRules]); + + return { + ...requestState, + result: rule, + }; +} + +export function useCombinedRulesMatching( + ruleName: string | undefined, + ruleSourceName: string | undefined +): AsyncRequestState { + const requestState = useCombinedRulesLoader(ruleSourceName); + const combinedRules = useCombinedRuleNamespaces(ruleSourceName); + + const rules = useMemo(() => { + if (!ruleName || !ruleSourceName || combinedRules.length === 0) { + return []; + } + + const rules: CombinedRule[] = []; + + for (const namespace of combinedRules) { + for (const group of namespace.groups) { + for (const rule of group.rules) { + if (rule.name === ruleName) { + rules.push(rule); + } + } + } + } + + return rules; + }, [ruleName, ruleSourceName, combinedRules]); + + return { + ...requestState, + result: rules, + }; +} + +function useCombinedRulesLoader(ruleSourceName: string | undefined): AsyncRequestState { + const dispatch = useDispatch(); + const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); + const promRuleRequest = getRequestState(ruleSourceName, promRuleRequests); + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + const rulerRuleRequest = getRequestState(ruleSourceName, rulerRuleRequests); + + useEffect(() => { + if (!ruleSourceName) { + return; + } + + dispatch(fetchPromRulesAction(ruleSourceName)); + dispatch(fetchRulerRulesAction(ruleSourceName)); + }, [dispatch, ruleSourceName]); + + return { + loading: promRuleRequest.loading || rulerRuleRequest.loading, + error: promRuleRequest.error ?? isRulerNotSupportedResponse(rulerRuleRequest) ? undefined : rulerRuleRequest.error, + dispatched: promRuleRequest.dispatched && rulerRuleRequest.dispatched, + }; +} + +function getRequestState( + ruleSourceName: string | undefined, + slice: AsyncRequestMapSlice +): AsyncRequestState { + if (!ruleSourceName) { + return initialAsyncRequestState; + } + + const state = slice[ruleSourceName]; + + if (!state) { + return initialAsyncRequestState; + } + + return state; +} diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index dead309aef0..b1403265874 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -24,7 +24,7 @@ interface CacheValue { result: CombinedRuleNamespace[]; } -// this little monster combines prometheus rules and ruler rules to produce a unfied data structure +// this little monster combines prometheus rules and ruler rules to produce a unified data structure // can limit to a single rules source export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRuleNamespace[] { const promRulesResponses = useUnifiedAlertingSelector((state) => state.promRules); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index a67986cf8a2..4fbd81eeca6 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -38,16 +38,15 @@ import { makeAMLink } from '../utils/misc'; import { withAppEvents, withSerializedError } from '../utils/redux'; import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form'; import { - getRuleIdentifier, - hashRulerRule, + isCloudRuleIdentifier, isGrafanaRuleIdentifier, isGrafanaRulerRule, + isPrometheusRuleIdentifier, isRulerNotSupportedResponse, - ruleWithLocationToRuleIdentifier, - stringifyRuleIdentifier, } from '../utils/rules'; import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; import { backendSrv } from 'app/core/services/backend_srv'; +import * as ruleId from '../utils/rule-id'; import { isEmpty } from 'lodash'; export const fetchPromRulesAction = createAsyncThunk( @@ -122,7 +121,7 @@ export function fetchAllPromRulesAction(force = false): ThunkResult { }; } -async function findExistingRule(ruleIdentifier: RuleIdentifier): Promise { +async function findEditableRule(ruleIdentifier: RuleIdentifier): Promise { if (isGrafanaRuleIdentifier(ruleIdentifier)) { const namespaces = await fetchRulerRules(GRAFANA_RULES_SOURCE_NAME); // find namespace and group that contains the uid for the rule @@ -141,28 +140,44 @@ async function findExistingRule(ruleIdentifier: RuleIdentifier): Promise hashRulerRule(rule) === ruleHash); - if (rule) { - return { - group, - ruleSourceName, - namespace, - rule, - }; - } - } } + + if (isCloudRuleIdentifier(ruleIdentifier)) { + const { ruleSourceName, namespace, groupName } = ruleIdentifier; + const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName); + + if (!group) { + return null; + } + + const rule = group.rules.find((rule) => { + const identifier = ruleId.fromRulerRule(ruleSourceName, namespace, group.name, rule); + return ruleId.equal(identifier, ruleIdentifier); + }); + + if (!rule) { + return null; + } + + return { + group, + ruleSourceName, + namespace, + rule, + }; + } + + if (isPrometheusRuleIdentifier(ruleIdentifier)) { + throw new Error('Native prometheus rules can not be edited in grafana.'); + } + return null; } -export const fetchExistingRuleAction = createAsyncThunk( - 'unifiedalerting/fetchExistingRule', +export const fetchEditableRuleAction = createAsyncThunk( + 'unifiedalerting/fetchEditableRule', (ruleIdentifier: RuleIdentifier): Promise => - withSerializedError(findExistingRule(ruleIdentifier)) + withSerializedError(findEditableRule(ruleIdentifier)) ); async function deleteRule(ruleWithLocation: RuleWithLocation): Promise { @@ -185,7 +200,10 @@ async function deleteRule(ruleWithLocation: RuleWithLocation): Promise { }); } -export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult { +export function deleteRuleAction( + ruleIdentifier: RuleIdentifier, + options: { navigateTo?: string } = {} +): ThunkResult { /* * fetch the rules group from backend, delete group if it is found and+ * reload ruler rules @@ -193,7 +211,7 @@ export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult { withAppEvents( (async () => { - const ruleWithLocation = await findExistingRule(ruleIdentifier); + const ruleWithLocation = await findEditableRule(ruleIdentifier); if (!ruleWithLocation) { throw new Error('Rule not found.'); } @@ -201,6 +219,10 @@ export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult source.name === name); } -export function getRulesSourceByName( - name: string -): DataSourceInstanceSettings | typeof GRAFANA_RULES_SOURCE_NAME | undefined { +export function getRulesSourceByName(name: string): RulesSource | undefined { if (name === GRAFANA_RULES_SOURCE_NAME) { return GRAFANA_RULES_SOURCE_NAME; } diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 6c6e38e2673..c0b93c13190 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -1,6 +1,17 @@ import { urlUtil, UrlQueryMap } from '@grafana/data'; -import { RuleFilterState } from 'app/types/unified-alerting'; +import { CombinedRule, RuleFilterState, RulesSource } from 'app/types/unified-alerting'; import { ALERTMANAGER_NAME_QUERY_KEY } from './constants'; +import { getRulesSourceName } from './datasource'; +import * as ruleId from './rule-id'; + +export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string { + const sourceName = getRulesSourceName(ruleSource); + const identifier = ruleId.fromCombinedRule(sourceName, rule); + const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier)); + const paramSource = encodeURIComponent(sourceName); + + return urlUtil.renderUrl(`/alerting/${paramSource}/${paramId}/view`, { returnTo }); +} export function createExploreLink(dataSourceName: string, query: string) { return urlUtil.renderUrl('explore', { @@ -14,20 +25,6 @@ export function createExploreLink(dataSourceName: string, query: string) { }); } -// used to hash rules -export function hash(value: string): number { - let hash = 0; - if (value.length === 0) { - return hash; - } - for (var i = 0; i < value.length; i++) { - var char = value.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer - } - return hash; -} - export function arrayToRecord(items: Array<{ key: string; value: string }>): Record { return items.reduce>((rec, { key, value }) => { rec[key] = value; diff --git a/public/app/features/alerting/unified/utils/query.test.ts b/public/app/features/alerting/unified/utils/query.test.ts new file mode 100644 index 00000000000..206799e4bfb --- /dev/null +++ b/public/app/features/alerting/unified/utils/query.test.ts @@ -0,0 +1,118 @@ +import { DataSourceJsonData, PluginMeta } from '@grafana/data'; +import { alertRuleToQueries } from './query'; +import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; + +describe('alertRuleToQueries', () => { + it('it should convert grafana alert', () => { + const combinedRule: CombinedRule = { + name: 'Test alert', + query: 'up', + labels: {}, + annotations: {}, + group: { + name: 'Prom up alert', + rules: [], + }, + namespace: { + rulesSource: GRAFANA_RULES_SOURCE_NAME, + name: 'Alerts', + groups: [], + }, + rulerRule: { + for: '', + annotations: {}, + labels: {}, + grafana_alert: grafanaAlert, + }, + }; + + const result = alertRuleToQueries(combinedRule); + expect(result).toEqual(grafanaAlert.data); + }); + + it('shoulds convert cloud alert', () => { + const combinedRule: CombinedRule = { + name: 'cloud test', + labels: {}, + query: 'up == 0', + annotations: {}, + group: { + name: 'test', + rules: [], + }, + namespace: { + name: 'prom test alerts', + groups: [], + rulesSource: { + name: 'prom test', + type: 'prometheus', + uid: 'asdf23', + id: 1, + meta: {} as PluginMeta, + jsonData: {} as DataSourceJsonData, + }, + }, + }; + + const result = alertRuleToQueries(combinedRule); + expect(result).toEqual([ + { + refId: 'A', + datasourceUid: 'asdf23', + queryType: '', + model: { + refId: 'A', + expr: 'up == 0', + }, + relativeTimeRange: { + from: 360, + to: 0, + }, + }, + ]); + }); +}); + +const grafanaAlert = { + condition: 'B', + exec_err_state: GrafanaAlertStateDecision.Alerting, + namespace_uid: 'namespaceuid123', + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'Test alert', + uid: 'asdf23', + data: [ + { + refId: 'A', + queryType: '', + relativeTimeRange: { from: 600, to: 0 }, + datasourceUid: 'asdf51', + model: { + expr: 'up', + refId: 'A', + }, + }, + { + refId: 'B', + queryType: '', + relativeTimeRange: { from: 0, to: 0 }, + datasourceUid: '-100', + model: { + conditions: [ + { + evaluator: { params: [1], type: 'lt' }, + operator: { type: 'and' }, + query: { params: ['A'] }, + reducer: { params: [], type: 'last' }, + type: 'query', + }, + ], + datasource: '__expr__', + hide: false, + refId: 'B', + type: 'classic_conditions', + }, + }, + ], +}; diff --git a/public/app/features/alerting/unified/utils/query.ts b/public/app/features/alerting/unified/utils/query.ts new file mode 100644 index 00000000000..835c5f33f24 --- /dev/null +++ b/public/app/features/alerting/unified/utils/query.ts @@ -0,0 +1,67 @@ +import { DataQuery, DataSourceInstanceSettings } from '@grafana/data'; +import { LokiQuery } from 'app/plugins/datasource/loki/types'; +import { PromQuery } from 'app/plugins/datasource/prometheus/types'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { isCloudRulesSource, isGrafanaRulesSource } from './datasource'; +import { isGrafanaRulerRule } from './rules'; + +export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null): AlertQuery[] { + if (!combinedRule) { + return []; + } + const { namespace, rulerRule } = combinedRule; + const { rulesSource } = namespace; + + if (isGrafanaRulesSource(rulesSource)) { + if (isGrafanaRulerRule(rulerRule)) { + return rulerRule.grafana_alert.data; + } + } + + if (isCloudRulesSource(rulesSource)) { + const model = cloudAlertRuleToModel(rulesSource, combinedRule); + + return [ + { + refId: model.refId, + datasourceUid: rulesSource.uid, + queryType: '', + model, + relativeTimeRange: { + from: 360, + to: 0, + }, + }, + ]; + } + + return []; +} + +function cloudAlertRuleToModel(dsSettings: DataSourceInstanceSettings, rule: CombinedRule): DataQuery { + const refId = 'A'; + + switch (dsSettings.type) { + case 'prometheus': { + const query: PromQuery = { + refId, + expr: rule.query, + }; + + return query; + } + + case 'loki': { + const query: LokiQuery = { + refId, + expr: rule.query, + }; + + return query; + } + + default: + throw new Error(`Query for datasource type ${dsSettings.type} is currently not supported by cloud alert rules.`); + } +} diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts new file mode 100644 index 00000000000..4081c931afb --- /dev/null +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -0,0 +1,213 @@ +import { CombinedRule, Rule, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting'; +import { Annotations, Labels, RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { + isAlertingRule, + isAlertingRulerRule, + isCloudRuleIdentifier, + isGrafanaRuleIdentifier, + isGrafanaRulerRule, + isPrometheusRuleIdentifier, + isRecordingRule, + isRecordingRulerRule, +} from './rules'; + +export function fromRulerRule( + ruleSourceName: string, + namespace: string, + groupName: string, + rule: RulerRuleDTO +): RuleIdentifier { + if (isGrafanaRulerRule(rule)) { + return { uid: rule.grafana_alert.uid! }; + } + return { + ruleSourceName, + namespace, + groupName, + rulerRuleHash: hashRulerRule(rule), + }; +} + +export function fromRule(ruleSourceName: string, namespace: string, groupName: string, rule: Rule): RuleIdentifier { + return { + ruleSourceName, + namespace, + groupName, + ruleHash: hashRule(rule), + }; +} + +export function fromCombinedRule(ruleSourceName: string, rule: CombinedRule): RuleIdentifier { + const namespaceName = rule.namespace.name; + const groupName = rule.group.name; + + if (rule.rulerRule) { + return fromRulerRule(ruleSourceName, namespaceName, groupName, rule.rulerRule); + } + + if (rule.promRule) { + return fromRule(ruleSourceName, namespaceName, groupName, rule.promRule); + } + + throw new Error('Could not create an id for a rule that is missing both `rulerRule` and `promRule`.'); +} + +export function fromRuleWithLocation(rule: RuleWithLocation): RuleIdentifier { + return fromRulerRule(rule.ruleSourceName, rule.namespace, rule.group.name, rule.rule); +} + +export function equal(a: RuleIdentifier, b: RuleIdentifier) { + if (isGrafanaRuleIdentifier(a) && isGrafanaRuleIdentifier(b)) { + return a.uid === b.uid; + } + + if (isCloudRuleIdentifier(a) && isCloudRuleIdentifier(b)) { + return ( + a.groupName === b.groupName && + a.namespace === b.namespace && + a.rulerRuleHash === b.rulerRuleHash && + a.ruleSourceName === b.ruleSourceName + ); + } + + if (isPrometheusRuleIdentifier(a) && isPrometheusRuleIdentifier(b)) { + return ( + a.groupName === b.groupName && + a.namespace === b.namespace && + a.ruleHash === b.ruleHash && + a.ruleSourceName === b.ruleSourceName + ); + } + + return false; +} + +const cloudRuleIdentifierPrefix = 'cri'; +const prometheusRuleIdentifierPrefix = 'pri'; + +function escapeDollars(value: string): string { + return value.replace(/\$/g, '_DOLLAR_'); +} + +function unesacapeDollars(value: string): string { + return value.replace(/\_DOLLAR\_/g, '$'); +} + +export function parse(value: string, decodeFromUri = false): RuleIdentifier { + const source = decodeFromUri ? decodeURIComponent(value) : value; + const parts = source.split('$'); + + if (parts.length === 1) { + return { uid: value }; + } + + if (parts.length === 5) { + const [prefix, ruleSourceName, namespace, groupName, hash] = parts.map(unesacapeDollars); + + if (prefix === cloudRuleIdentifierPrefix) { + return { ruleSourceName, namespace, groupName, rulerRuleHash: Number(hash) }; + } + + if (prefix === prometheusRuleIdentifierPrefix) { + return { ruleSourceName, namespace, groupName, ruleHash: Number(hash) }; + } + } + + throw new Error(`Failed to parse rule location: ${value}`); +} + +export function tryParse(value: string | undefined, decodeFromUri = false): RuleIdentifier | undefined { + if (!value) { + return; + } + + try { + return parse(value, decodeFromUri); + } catch (error) { + return; + } +} + +export function stringifyIdentifier(identifier: RuleIdentifier): string { + if (isGrafanaRuleIdentifier(identifier)) { + return identifier.uid; + } + + if (isCloudRuleIdentifier(identifier)) { + return [ + cloudRuleIdentifierPrefix, + identifier.ruleSourceName, + identifier.namespace, + identifier.groupName, + identifier.rulerRuleHash, + ] + .map(String) + .map(escapeDollars) + .join('$'); + } + + return [ + prometheusRuleIdentifierPrefix, + identifier.ruleSourceName, + identifier.namespace, + identifier.groupName, + identifier.ruleHash, + ] + .map(String) + .map(escapeDollars) + .join('$'); +} + +function hash(value: string): number { + let hash = 0; + if (value.length === 0) { + return hash; + } + for (var i = 0; i < value.length; i++) { + var char = value.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash; +} + +// this is used to identify lotex rules, as they do not have a unique identifier +function hashRulerRule(rule: RulerRuleDTO): number { + if (isRecordingRulerRule(rule)) { + return hash(JSON.stringify([rule.record, rule.expr, hashLabelsOrAnnotations(rule.labels)])); + } else if (isAlertingRulerRule(rule)) { + return hash( + JSON.stringify([ + rule.alert, + rule.expr, + hashLabelsOrAnnotations(rule.annotations), + hashLabelsOrAnnotations(rule.labels), + ]) + ); + } else { + throw new Error('only recording and alerting ruler rules can be hashed'); + } +} + +function hashRule(rule: Rule): number { + if (isRecordingRule(rule)) { + return hash(JSON.stringify([rule.type, rule.query, hashLabelsOrAnnotations(rule.labels)])); + } + + if (isAlertingRule(rule)) { + return hash( + JSON.stringify([ + rule.type, + rule.query, + hashLabelsOrAnnotations(rule.annotations), + hashLabelsOrAnnotations(rule.labels), + ]) + ); + } + + throw new Error('only recording and alerting rules can be hashed'); +} + +function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string { + return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0]))); +} diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 40d74a7e350..064404ed5ae 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -1,7 +1,5 @@ import { - Annotations, GrafanaAlertState, - Labels, PromAlertingRuleState, PromRuleType, RulerAlertingRuleDTO, @@ -14,33 +12,32 @@ import { AlertingRule, CloudRuleIdentifier, GrafanaRuleIdentifier, + PrometheusRuleIdentifier, PromRuleWithLocation, RecordingRule, Rule, RuleIdentifier, RuleNamespace, - RuleWithLocation, } from 'app/types/unified-alerting'; import { AsyncRequestState } from './redux'; import { RULER_NOT_SUPPORTED_MSG } from './constants'; -import { hash } from './misc'; import { capitalize } from 'lodash'; import { State } from '../components/StateTag'; -export function isAlertingRule(rule: Rule): rule is AlertingRule { - return rule.type === PromRuleType.Alerting; +export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule { + return typeof rule === 'object' && rule.type === PromRuleType.Alerting; } export function isRecordingRule(rule: Rule): rule is RecordingRule { return rule.type === PromRuleType.Recording; } -export function isAlertingRulerRule(rule: RulerRuleDTO): rule is RulerAlertingRuleDTO { - return 'alert' in rule; +export function isAlertingRulerRule(rule?: RulerRuleDTO): rule is RulerAlertingRuleDTO { + return typeof rule === 'object' && 'alert' in rule; } -export function isRecordingRulerRule(rule: RulerRuleDTO): rule is RulerRecordingRuleDTO { - return 'record' in rule; +export function isRecordingRulerRule(rule?: RulerRuleDTO): rule is RulerRecordingRuleDTO { + return typeof rule === 'object' && 'record' in rule; } export function isGrafanaRulerRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO { @@ -55,88 +52,16 @@ export function isRulerNotSupportedResponse(resp: AsyncRequestState) { return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG; } -function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string { - return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0]))); +export function isGrafanaRuleIdentifier(identifier: RuleIdentifier): identifier is GrafanaRuleIdentifier { + return 'uid' in identifier; } -// this is used to identify lotex rules, as they do not have a unique identifier -export function hashRulerRule(rule: RulerRuleDTO): number { - if (isRecordingRulerRule(rule)) { - return hash(JSON.stringify([rule.record, rule.expr, hashLabelsOrAnnotations(rule.labels)])); - } else if (isAlertingRulerRule(rule)) { - return hash( - JSON.stringify([ - rule.alert, - rule.expr, - hashLabelsOrAnnotations(rule.annotations), - hashLabelsOrAnnotations(rule.labels), - ]) - ); - } else { - throw new Error('only recording and alerting ruler rules can be hashed'); - } +export function isCloudRuleIdentifier(identifier: RuleIdentifier): identifier is CloudRuleIdentifier { + return 'rulerRuleHash' in identifier; } -export function isGrafanaRuleIdentifier(location: RuleIdentifier): location is GrafanaRuleIdentifier { - return 'uid' in location; -} - -export function isCloudRuleIdentifier(location: RuleIdentifier): location is CloudRuleIdentifier { - return 'ruleSourceName' in location; -} - -function escapeDollars(value: string): string { - return value.replace(/\$/g, '_DOLLAR_'); -} -function unesacapeDollars(value: string): string { - return value.replace(/\_DOLLAR\_/g, '$'); -} - -export function stringifyRuleIdentifier(location: RuleIdentifier): string { - if (isGrafanaRuleIdentifier(location)) { - return location.uid; - } - return [location.ruleSourceName, location.namespace, location.groupName, location.ruleHash] - .map(String) - .map(escapeDollars) - .join('$'); -} - -export function parseRuleIdentifier(location: string): RuleIdentifier { - const parts = location.split('$'); - if (parts.length === 1) { - return { uid: location }; - } else if (parts.length === 4) { - const [ruleSourceName, namespace, groupName, ruleHash] = parts.map(unesacapeDollars); - return { ruleSourceName, namespace, groupName, ruleHash: Number(ruleHash) }; - } - throw new Error(`Failed to parse rule location: ${location}`); -} - -export function getRuleIdentifier( - ruleSourceName: string, - namespace: string, - groupName: string, - rule: RulerRuleDTO -): RuleIdentifier { - if (isGrafanaRulerRule(rule)) { - return { uid: rule.grafana_alert.uid! }; - } - return { - ruleSourceName, - namespace, - groupName, - ruleHash: hashRulerRule(rule), - }; -} - -export function ruleWithLocationToRuleIdentifier(ruleWithLocation: RuleWithLocation): RuleIdentifier { - return getRuleIdentifier( - ruleWithLocation.ruleSourceName, - ruleWithLocation.namespace, - ruleWithLocation.group.name, - ruleWithLocation.rule - ); +export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifier is PrometheusRuleIdentifier { + return 'ruleHash' in identifier; } export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState): string { diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 887716d4be0..81c664a6677 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -448,6 +448,21 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') ), }, + { + path: '/alerting/:sourceName/:id/view', + pageClass: 'page-alerting', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertingRule"*/ 'app/features/alerting/unified/RuleViewer') + ), + }, + { + path: '/alerting/:sourceName/:name/find', + pageClass: 'page-alerting', + component: SafeDynamicImport( + () => + import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer') + ), + }, { path: '/playlists', component: SafeDynamicImport( diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 560b5ec9da3..4ffcf9fe57f 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -112,16 +112,23 @@ export interface CloudRuleIdentifier { ruleSourceName: string; namespace: string; groupName: string; - ruleHash: number; -} - -export interface RuleFilterState { - queryString?: string; - dataSource?: string; - alertState?: string; + rulerRuleHash: number; } export interface GrafanaRuleIdentifier { uid: string; } -export type RuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier; +// Rule read directly from Prometheus without existing in the ruler API +export interface PrometheusRuleIdentifier { + ruleSourceName: string; + namespace: string; + groupName: string; + ruleHash: number; +} + +export type RuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier | PrometheusRuleIdentifier; +export interface RuleFilterState { + queryString?: string; + dataSource?: string; + alertState?: string; +}