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 <domas.lapinskas@grafana.com>

* Update public/app/features/alerting/unified/hooks/useCombinedRule.ts

Co-authored-by: Domas <domas.lapinskas@grafana.com>

* 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 <peter.hlmbrg@gmail.com>
Co-authored-by: Domas <domas.lapinskas@grafana.com>
This commit is contained in:
Marcus Andersson 2021-07-01 12:02:41 +02:00 committed by GitHub
parent 084c9c8746
commit c9e28044f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1671 additions and 262 deletions

View File

@ -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(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<RedirectToRuleViewer {...mockRoute('prom alert', 'test prom')} />
</Router>
</Provider>
);
};
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: {},
};
};

View File

@ -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 (
<RuleViewerLayout title={pageTitle}>
<Alert title={`Failed to load rules from ${sourceName}`}>
<details className={styles.errorMessage}>
{error.message}
<br />
{!!error?.stack && error.stack}
</details>
</Alert>
</RuleViewerLayout>
);
}
if (loading || !dispatched || !Array.isArray(rules)) {
return (
<RuleViewerLayout title={pageTitle}>
<LoadingPlaceholder text="Loading rule..." />
</RuleViewerLayout>
);
}
if (!name || !sourceName) {
return <Redirect to="/notfound" />;
}
const rulesSource = getRulesSourceByName(sourceName);
if (!rulesSource) {
return (
<RuleViewerLayout title={pageTitle}>
<Alert title="Could not view rule">
<details className={styles.errorMessage}>{`Could not find data source with name: ${sourceName}.`}</details>
</Alert>
</RuleViewerLayout>
);
}
if (rules.length === 1) {
const [rule] = rules;
return <Redirect to={createViewLink(rulesSource, rule, '/alerting/list')} />;
}
return (
<RuleViewerLayout title={pageTitle}>
<div>
Several rules in <span className={styles.param}>{sourceName}</span> matched the name{' '}
<span className={styles.param}>{name}</span>, please select the rule you want to view.
</div>
<div className={styles.rules}>
{rules.map((rule, index) => {
return (
<Card
key={`${rule.name}-${index}`}
heading={rule.name}
href={createViewLink(rulesSource, rule, '/alerting/list')}
>
<Card.Meta separator={''}>
<Icon name="folder" />
<span className={styles.namespace}>{`${rule.namespace.name} / ${rule.group.name}`}</span>
</Card.Meta>
<Card.Tags>
<AlertLabels labels={rule.labels} />
</Card.Tags>
</Card>
);
})}
</div>
</RuleViewerLayout>
);
}
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' });

View File

@ -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<ExistingRuleEditorProps> = ({ identifier }) => {
useEffect(() => {
if (!dispatched) {
dispatch(fetchExistingRuleAction(identifier));
dispatch(fetchEditableRuleAction(identifier));
}
}, [dispatched, dispatch, identifier]);
@ -58,9 +58,10 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>;
const RuleEditor: FC<RuleEditorProps> = ({ 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 <ExistingRuleEditor key={id} identifier={identifier} />;
}
if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) {

View File

@ -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(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<RuleViewer {...mockRoute} />
</Router>
</Provider>
);
};
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: {},
};

View File

@ -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 (
<RuleViewerLayout title={pageTitle}>
<Alert title={errorTitle}>
<details className={styles.errorMessage}>{errorMessage}</details>
</Alert>
</RuleViewerLayout>
);
}
const rulesSource = getRulesSourceByName(sourceName);
if (loading) {
return (
<RuleViewerLayout title={pageTitle}>
<LoadingPlaceholder text="Loading rule..." />
</RuleViewerLayout>
);
}
if (error || !rulesSource) {
return (
<RuleViewerLayout title={pageTitle}>
<Alert title={errorTitle}>
<details className={styles.errorMessage}>
{error?.message ?? errorMessage}
<br />
{!!error?.stack && error.stack}
</details>
</Alert>
</RuleViewerLayout>
);
}
if (!rule) {
return (
<RuleViewerLayout title={pageTitle}>
<span>Rule could not be found.</span>
</RuleViewerLayout>
);
}
const annotations = Object.entries(rule.annotations).filter(([_, value]) => !!value.trim());
return (
<RuleViewerLayout wrapInContent={false} title={pageTitle}>
<RuleViewerLayoutContent>
<div>
<h4>
<Icon name="bell" size="lg" /> {rule.name}
</h4>
<RuleState rule={rule} isCreating={false} isDeleting={false} />
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
</div>
<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} />
<DetailsField label="Namespace / Group">{`${rule.namespace.name} / ${rule.group.name}`}</DetailsField>
</div>
</div>
<div>
<RuleDetailsMatchingInstances promRule={rule.promRule} />
</div>
</RuleViewerLayoutContent>
{data && Object.keys(data).length > 0 && (
<>
<div className={styles.queriesTitle}>
Query results <PanelChromeLoadingIndicator loading={isLoading(data)} onCancel={() => runner.cancel()} />
</div>
<RuleViewerLayoutContent padding={0}>
<div className={styles.queries}>
{queries.map((query) => {
return (
<div key={query.refId} className={styles.query}>
<RuleViewerVisualization query={query} data={data && data[query.refId]} />
</div>
);
})}
</div>
</RuleViewerLayoutContent>
</>
)}
</RuleViewerLayout>
);
}
function isLoading(data: Record<string, PanelData>): 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' });

View File

@ -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 <RadioButtonGroup options={panels} value={value} onChange={onChange} size={size} />;
}
function getSupportedPanels(): Array<SelectableValue<SupportedPanelPlugins>> {
return Object.values(config.panels).reduce((panels: Array<SelectableValue<SupportedPanelPlugins>>, 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;
}
}

View File

@ -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<Props> = ({
data,
dsSettings,

View File

@ -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<Props> = ({ 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<Props> = ({ data, currentPanel, changePanel }) => {
return (
<div className={styles.wrapper}>
<div className={styles.buttonGroup}>
<RadioButtonGroup options={panels} value={currentPanel} onChange={changePanel} />
<PanelPluginsButtonGroup onChange={changePanel} value={currentPanel} />
</div>
<AutoSizer>
{({ width }) => {
@ -57,21 +55,11 @@ export const VizWrapper: FC<Props> = ({ 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;

View File

@ -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 (
<Page>
<PageToolbar title={title} pageIcon="bell" onGoBack={() => locationService.push('/alerting/list')} />
<div className={styles.content}>{wrapInContent ? <RuleViewerLayoutContent {...props} /> : children}</div>
</Page>
);
}
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 <div className={styles.wrapper}>{children}</div>;
}
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)};
`,
};
};

View File

@ -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<SupportedPanelPlugins>(defaultPanel);
const dsSettings = getDataSourceSrv().getInstanceSettings(query.datasourceUid);
const [options, setOptions] = useState<PanelOptions>({
frameIndex: 0,
showHeader: true,
});
if (!data) {
return null;
}
if (!dsSettings) {
return (
<div className={styles.content}>
<Alert title="Could not find datasource for query" />
<CodeEditor
width="100%"
height="250px"
language="json"
showLineNumbers={false}
showMiniMap={false}
value={JSON.stringify(query, null, '\t')}
readOnly={true}
/>
</div>
);
}
return (
<div className={styles.content}>
<AutoSizer>
{({ width, height }) => {
return (
<div style={{ width, height }}>
<div className={styles.header}>
<div>
{`Query ${query.refId}`}
<span className={styles.dataSource}>({dsSettings.name})</span>
</div>
<div className={styles.actions}>
<PanelPluginsButtonGroup onChange={setPanel} value={panel} size="sm" />
{!isExpressionQuery(query.model) && (
<>
<div className={styles.spacing} />
<LinkButton
size="sm"
variant="secondary"
icon="compass"
target="_blank"
href={createExploreLink(dsSettings, query)}
>
View in Explore
</LinkButton>
</>
)}
</div>
</div>
<PanelRenderer
height={height - theme.spacing.gridSize * headerHeight}
width={width}
data={data}
pluginId={panel}
title=""
onOptionsChange={setOptions}
options={options}
/>
</div>
);
}}
</AutoSizer>
</div>
);
}
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;
`,
};
};

View File

@ -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<Props> = ({ rule }) => {
const styles = useStyles2(getStyles);
const {
promRule,
namespace: { rulesSource },
@ -28,29 +24,6 @@ export const RuleDetails: FC<Props> = ({ 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 (
<div>
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
@ -61,41 +34,14 @@ export const RuleDetails: FC<Props> = ({ rule }) => {
<AlertLabels labels={rule.labels} />
</DetailsField>
)}
{isCloudRulesSource(rulesSource) && (
<DetailsField
label="Expression"
className={cx({ [styles.exprRow]: !!annotations.length })}
horizontal={true}
>
<Expression expression={rule.query} rulesSource={rulesSource} />
</DetailsField>
)}
{annotations.map(([key, value]) => (
<AnnotationDetailsField key={key} annotationKey={key} value={value} />
))}
<RuleDetailsExpression rulesSource={rulesSource} rule={rule} annotations={annotations} />
<RuleDetailsAnnotations annotations={annotations} />
</div>
<div className={styles.rightSide}>
{!!dataSources.length && (
<DetailsField label="Data source">
{dataSources.map(({ name, icon }) => (
<div key={name}>
{icon && (
<>
<img className={styles.dataSourceIcon} src={icon} />{' '}
</>
)}
{name}
</div>
))}
</DetailsField>
)}
<RuleDetailsDataSources rulesSource={rulesSource} rule={rule} />
</div>
</div>
{promRule && isAlertingRule(promRule) && !!promRule.alerts?.length && (
<DetailsField label="Matching instances" horizontal={true}>
<AlertInstancesTable instances={promRule.alerts} />
</DetailsField>
)}
<RuleDetailsMatchingInstances promRule={promRule} />
</div>
);
};
@ -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)};
`,
});

View File

@ -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<Props> = ({ 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<Props> = ({ 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(
<LinkButton
className={style.button}
size="xs"
key="view"
variant="secondary"
icon="eye"
href={createViewLink(rulesSource, rule, returnTo)}
>
View
</LinkButton>
);
}
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(
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
@ -166,6 +177,10 @@ export const RuleDetailsActionButtons: FC<Props> = ({ 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;

View File

@ -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 (
<div className={styles.annotations}>
{annotations.map(([key, value]) => (
<AnnotationDetailsField key={key} annotationKey={key} value={value} />
))}
</div>
);
}
const getStyles = () => ({
annotations: css`
margin-top: 46px;
`,
});

View File

@ -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<string, { name: string; icon?: string }>);
return Object.values(unique);
}
return [];
}, [rule, rulesSource]);
if (dataSources.length === 0) {
return null;
}
return (
<DetailsField label="Data source">
{dataSources.map(({ name, icon }, index) => (
<div key={name}>
{icon && (
<>
<img className={styles.dataSourceIcon} src={icon} />{' '}
</>
)}
{name}
</div>
))}
</DetailsField>
);
}
function getStyles(theme: GrafanaTheme2) {
const size = theme.spacing(2);
return {
dataSourceIcon: css`
width: ${size};
height: ${size};
`,
};
}

View File

@ -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 (
<DetailsField label="Expression" horizontal={true} className={cx({ [styles.exprRow]: !!annotations.length })}>
<Expression expression={rule.query} rulesSource={rulesSource} />
</DetailsField>
);
}
const getStyles = () => ({
exprRow: css`
margin-bottom: 46px;
`,
});

View File

@ -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 (
<DetailsField label="Matching instances" horizontal={true}>
<AlertInstancesTable instances={promRule.alerts} />
</DetailsField>
);
}

View File

@ -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<CombinedRule> {
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<CombinedRule[]> {
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<void> {
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<RulerRulesConfigDTO | RuleNamespace[] | null>
): AsyncRequestState<RulerRulesConfigDTO | RuleNamespace[] | null> {
if (!ruleSourceName) {
return initialAsyncRequestState;
}
const state = slice[ruleSourceName];
if (!state) {
return initialAsyncRequestState;
}
return state;
}

View File

@ -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);

View File

@ -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<void> {
};
}
async function findExistingRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> {
async function findEditableRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> {
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<RuleWit
}
}
}
} else {
const { ruleSourceName, namespace, groupName, ruleHash } = ruleIdentifier;
const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName);
if (group) {
const rule = group.rules.find((rule) => 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<RuleWithLocation | null> =>
withSerializedError(findExistingRule(ruleIdentifier))
withSerializedError(findEditableRule(ruleIdentifier))
);
async function deleteRule(ruleWithLocation: RuleWithLocation): Promise<void> {
@ -185,7 +200,10 @@ async function deleteRule(ruleWithLocation: RuleWithLocation): Promise<void> {
});
}
export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult<void> {
export function deleteRuleAction(
ruleIdentifier: RuleIdentifier,
options: { navigateTo?: string } = {}
): ThunkResult<void> {
/*
* 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<vo
return async (dispatch) => {
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<vo
// refetch rules for this rules source
dispatch(fetchRulerRulesAction(ruleWithLocation.ruleSourceName));
dispatch(fetchPromRulesAction(ruleWithLocation.ruleSourceName));
if (options.navigateTo) {
locationService.replace(options.navigateTo);
}
})(),
{
successMessage: 'Rule deleted.',
@ -216,7 +238,7 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
// if we're updating a rule...
if (existing) {
// refetch it so we always have the latest greatest
const freshExisting = await findExistingRule(ruleWithLocationToRuleIdentifier(existing));
const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing));
if (!freshExisting) {
throw new Error('Rule not found.');
}
@ -232,7 +254,7 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
),
};
await setRulerRuleGroup(dataSourceName, namespace, payload);
return getRuleIdentifier(dataSourceName, namespace, group, formRule);
return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule);
}
}
@ -251,7 +273,7 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
};
await setRulerRuleGroup(dataSourceName, namespace, payload);
return getRuleIdentifier(dataSourceName, namespace, group, formRule);
return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule);
} else {
throw new Error('Data source and location must be specified');
}
@ -264,7 +286,7 @@ async function saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocati
// updating an existing rule...
if (existing) {
// refetch it to be sure we have the latest
const freshExisting = await findExistingRule(ruleWithLocationToRuleIdentifier(existing));
const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing));
if (!freshExisting) {
throw new Error('Rule not found.');
}
@ -345,7 +367,7 @@ export const saveRuleFormAction = createAsyncThunk(
locationService.push(redirectOnSave);
} else {
// redirect to edit page
const newLocation = `/alerting/${encodeURIComponent(stringifyRuleIdentifier(identifier))}/edit`;
const newLocation = `/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`;
if (locationService.getLocation().pathname !== newLocation) {
locationService.replace(newLocation);
}

View File

@ -3,7 +3,7 @@ import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
import {
fetchAlertManagerConfigAction,
fetchAmAlertsAction,
fetchExistingRuleAction,
fetchEditableRuleAction,
fetchGrafanaNotifiersAction,
fetchPromRulesAction,
fetchRulerRulesAction,
@ -26,7 +26,7 @@ export const reducer = combineReducers({
.reducer,
ruleForm: combineReducers({
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
existingRule: createAsyncSlice('existingRule', fetchExistingRuleAction).reducer,
existingRule: createAsyncSlice('existingRule', fetchEditableRuleAction).reducer,
}),
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,

View File

@ -61,9 +61,7 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings<Da
return getAllDataSources().find((source) => source.name === name);
}
export function getRulesSourceByName(
name: string
): DataSourceInstanceSettings<DataSourceJsonData> | typeof GRAFANA_RULES_SOURCE_NAME | undefined {
export function getRulesSourceByName(name: string): RulesSource | undefined {
if (name === GRAFANA_RULES_SOURCE_NAME) {
return GRAFANA_RULES_SOURCE_NAME;
}

View File

@ -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<string, string> {
return items.reduce<Record<string, string>>((rec, { key, value }) => {
rec[key] = value;

View File

@ -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',
},
},
],
};

View File

@ -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.`);
}
}

View File

@ -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])));
}

View File

@ -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<any>) {
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 {

View File

@ -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(

View File

@ -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;
}