mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
084c9c8746
commit
c9e28044f1
@ -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: {},
|
||||||
|
};
|
||||||
|
};
|
111
public/app/features/alerting/unified/RedirectToRuleViewer.tsx
Normal file
111
public/app/features/alerting/unified/RedirectToRuleViewer.tsx
Normal 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' });
|
@ -11,8 +11,8 @@ import { useDispatch } from 'react-redux';
|
|||||||
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
||||||
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
|
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
|
||||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||||
import { fetchExistingRuleAction } from './state/actions';
|
import { fetchEditableRuleAction } from './state/actions';
|
||||||
import { parseRuleIdentifier } from './utils/rules';
|
import * as ruleId from './utils/rule-id';
|
||||||
|
|
||||||
interface ExistingRuleEditorProps {
|
interface ExistingRuleEditorProps {
|
||||||
identifier: RuleIdentifier;
|
identifier: RuleIdentifier;
|
||||||
@ -26,7 +26,7 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dispatched) {
|
if (!dispatched) {
|
||||||
dispatch(fetchExistingRuleAction(identifier));
|
dispatch(fetchEditableRuleAction(identifier));
|
||||||
}
|
}
|
||||||
}, [dispatched, dispatch, identifier]);
|
}, [dispatched, dispatch, identifier]);
|
||||||
|
|
||||||
@ -58,9 +58,10 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
|
|||||||
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>;
|
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>;
|
||||||
|
|
||||||
const RuleEditor: FC<RuleEditorProps> = ({ match }) => {
|
const RuleEditor: FC<RuleEditorProps> = ({ match }) => {
|
||||||
const id = match.params.id;
|
const { id } = match.params;
|
||||||
if (id) {
|
const identifier = ruleId.tryParse(id, true);
|
||||||
const identifier = parseRuleIdentifier(decodeURIComponent(id));
|
|
||||||
|
if (identifier) {
|
||||||
return <ExistingRuleEditor key={id} identifier={identifier} />;
|
return <ExistingRuleEditor key={id} identifier={identifier} />;
|
||||||
}
|
}
|
||||||
if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) {
|
if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) {
|
||||||
|
138
public/app/features/alerting/unified/RuleViewer.test.tsx
Normal file
138
public/app/features/alerting/unified/RuleViewer.test.tsx
Normal 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: {},
|
||||||
|
};
|
195
public/app/features/alerting/unified/RuleViewer.tsx
Normal file
195
public/app/features/alerting/unified/RuleViewer.tsx
Normal 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' });
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -15,6 +15,7 @@ import { VizWrapper } from './VizWrapper';
|
|||||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
import { TABLE, TIMESERIES } from '../../utils/constants';
|
import { TABLE, TIMESERIES } from '../../utils/constants';
|
||||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PanelData;
|
data: PanelData;
|
||||||
@ -30,8 +31,6 @@ interface Props {
|
|||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SupportedPanelPlugins = 'timeseries' | 'table' | 'stat';
|
|
||||||
|
|
||||||
export const QueryWrapper: FC<Props> = ({
|
export const QueryWrapper: FC<Props> = ({
|
||||||
data,
|
data,
|
||||||
dsSettings,
|
dsSettings,
|
||||||
|
@ -2,12 +2,11 @@ import React, { FC, useState } from 'react';
|
|||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { GrafanaTheme2, PanelData } from '@grafana/data';
|
import { GrafanaTheme2, PanelData } from '@grafana/data';
|
||||||
import { config, PanelRenderer } from '@grafana/runtime';
|
import { PanelRenderer } from '@grafana/runtime';
|
||||||
import { RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { PanelOptions } from 'app/plugins/panel/table/models.gen';
|
import { PanelOptions } from 'app/plugins/panel/table/models.gen';
|
||||||
import { SupportedPanelPlugins } from './QueryWrapper';
|
|
||||||
import { useVizHeight } from '../../hooks/useVizHeight';
|
import { useVizHeight } from '../../hooks/useVizHeight';
|
||||||
import { STAT, TABLE, TIMESERIES } from '../../utils/constants';
|
import { SupportedPanelPlugins, PanelPluginsButtonGroup } from '../PanelPluginsButtonGroup';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PanelData;
|
data: PanelData;
|
||||||
@ -20,7 +19,6 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
|
|||||||
frameIndex: 0,
|
frameIndex: 0,
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
});
|
});
|
||||||
const panels = getSupportedPanels();
|
|
||||||
const vizHeight = useVizHeight(data, currentPanel, options.frameIndex);
|
const vizHeight = useVizHeight(data, currentPanel, options.frameIndex);
|
||||||
const styles = useStyles2(getStyles(vizHeight));
|
const styles = useStyles2(getStyles(vizHeight));
|
||||||
|
|
||||||
@ -31,7 +29,7 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={styles.buttonGroup}>
|
<div className={styles.buttonGroup}>
|
||||||
<RadioButtonGroup options={panels} value={currentPanel} onChange={changePanel} />
|
<PanelPluginsButtonGroup onChange={changePanel} value={currentPanel} />
|
||||||
</div>
|
</div>
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ width }) => {
|
{({ 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) => ({
|
const getStyles = (visHeight: number) => (theme: GrafanaTheme2) => ({
|
||||||
wrapper: css`
|
wrapper: css`
|
||||||
padding: 0 ${theme.spacing(2)};
|
padding: 0 ${theme.spacing(2)};
|
||||||
height: ${visHeight + theme.spacing.gridSize * 4}px;
|
height: ${visHeight + theme.spacing.gridSize * 4}px;
|
||||||
`,
|
`,
|
||||||
autoSizerWrapper: css`
|
|
||||||
width: 100%;
|
|
||||||
height: 200px;
|
|
||||||
`,
|
|
||||||
buttonGroup: css`
|
buttonGroup: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
@ -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)};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -1,18 +1,15 @@
|
|||||||
import { CombinedRule } from 'app/types/unified-alerting';
|
import { CombinedRule } from 'app/types/unified-alerting';
|
||||||
import React, { FC, useMemo } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
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 { AlertLabels } from '../AlertLabels';
|
||||||
import { AlertInstancesTable } from './AlertInstancesTable';
|
|
||||||
import { DetailsField } from '../DetailsField';
|
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 { RuleDetailsActionButtons } from './RuleDetailsActionButtons';
|
||||||
|
import { RuleDetailsDataSources } from './RuleDetailsDataSources';
|
||||||
|
import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances';
|
||||||
|
import { RuleDetailsExpression } from './RuleDetailsExpression';
|
||||||
|
import { RuleDetailsAnnotations } from './RuleDetailsAnnotations';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rule: CombinedRule;
|
rule: CombinedRule;
|
||||||
@ -20,7 +17,6 @@ interface Props {
|
|||||||
|
|
||||||
export const RuleDetails: FC<Props> = ({ rule }) => {
|
export const RuleDetails: FC<Props> = ({ rule }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
promRule,
|
promRule,
|
||||||
namespace: { rulesSource },
|
namespace: { rulesSource },
|
||||||
@ -28,29 +24,6 @@ export const RuleDetails: FC<Props> = ({ rule }) => {
|
|||||||
|
|
||||||
const annotations = Object.entries(rule.annotations).filter(([_, value]) => !!value.trim());
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
|
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
|
||||||
@ -61,41 +34,14 @@ export const RuleDetails: FC<Props> = ({ rule }) => {
|
|||||||
<AlertLabels labels={rule.labels} />
|
<AlertLabels labels={rule.labels} />
|
||||||
</DetailsField>
|
</DetailsField>
|
||||||
)}
|
)}
|
||||||
{isCloudRulesSource(rulesSource) && (
|
<RuleDetailsExpression rulesSource={rulesSource} rule={rule} annotations={annotations} />
|
||||||
<DetailsField
|
<RuleDetailsAnnotations annotations={annotations} />
|
||||||
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} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.rightSide}>
|
<div className={styles.rightSide}>
|
||||||
{!!dataSources.length && (
|
<RuleDetailsDataSources rulesSource={rulesSource} rule={rule} />
|
||||||
<DetailsField label="Data source">
|
|
||||||
{dataSources.map(({ name, icon }) => (
|
|
||||||
<div key={name}>
|
|
||||||
{icon && (
|
|
||||||
<>
|
|
||||||
<img className={styles.dataSourceIcon} src={icon} />{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</DetailsField>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{promRule && isAlertingRule(promRule) && !!promRule.alerts?.length && (
|
<RuleDetailsMatchingInstances promRule={promRule} />
|
||||||
<DetailsField label="Matching instances" horizontal={true}>
|
|
||||||
<AlertInstancesTable instances={promRule.alerts} />
|
|
||||||
</DetailsField>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -117,11 +63,4 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
width: 300px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
exprRow: css`
|
|
||||||
margin-bottom: 46px;
|
|
||||||
`,
|
|
||||||
dataSourceIcon: css`
|
|
||||||
width: ${theme.spacing(2)};
|
|
||||||
height: ${theme.spacing(2)};
|
|
||||||
`,
|
|
||||||
});
|
});
|
||||||
|
@ -10,8 +10,8 @@ import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
|
|||||||
import { deleteRuleAction } from '../../state/actions';
|
import { deleteRuleAction } from '../../state/actions';
|
||||||
import { Annotation } from '../../utils/constants';
|
import { Annotation } from '../../utils/constants';
|
||||||
import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
|
import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
|
||||||
import { createExploreLink } from '../../utils/misc';
|
import { createExploreLink, createViewLink } from '../../utils/misc';
|
||||||
import { getRuleIdentifier, stringifyRuleIdentifier } from '../../utils/rules';
|
import * as ruleId from '../../utils/rule-id';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rule: CombinedRule;
|
rule: CombinedRule;
|
||||||
@ -29,19 +29,19 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
const rightButtons: JSX.Element[] = [];
|
const rightButtons: JSX.Element[] = [];
|
||||||
|
|
||||||
const { isEditable } = useIsRuleEditable(rulerRule);
|
const { isEditable } = useIsRuleEditable(rulerRule);
|
||||||
|
const returnTo = location.pathname + location.search;
|
||||||
|
const isViewMode = inViewMode(location.pathname);
|
||||||
|
|
||||||
const deleteRule = () => {
|
const deleteRule = () => {
|
||||||
if (ruleToDelete && ruleToDelete.rulerRule) {
|
if (ruleToDelete && ruleToDelete.rulerRule) {
|
||||||
dispatch(
|
const identifier = ruleId.fromRulerRule(
|
||||||
deleteRuleAction(
|
|
||||||
getRuleIdentifier(
|
|
||||||
getRulesSourceName(ruleToDelete.namespace.rulesSource),
|
getRulesSourceName(ruleToDelete.namespace.rulesSource),
|
||||||
ruleToDelete.namespace.name,
|
ruleToDelete.namespace.name,
|
||||||
ruleToDelete.group.name,
|
ruleToDelete.group.name,
|
||||||
ruleToDelete.rulerRule
|
ruleToDelete.rulerRule
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
dispatch(deleteRuleAction(identifier, { navigateTo: isViewMode ? '/alerting/list' : undefined }));
|
||||||
setRuleToDelete(undefined);
|
setRuleToDelete(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -112,17 +112,28 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditable && rulerRule) {
|
if (!isViewMode) {
|
||||||
const editURL = urlUtil.renderUrl(
|
rightButtons.push(
|
||||||
`/alerting/${encodeURIComponent(
|
<LinkButton
|
||||||
stringifyRuleIdentifier(
|
className={style.button}
|
||||||
getRuleIdentifier(getRulesSourceName(rulesSource), namespace.name, group.name, rulerRule)
|
size="xs"
|
||||||
)
|
key="view"
|
||||||
)}/edit`,
|
variant="secondary"
|
||||||
{
|
icon="eye"
|
||||||
returnTo: location.pathname + location.search,
|
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(
|
rightButtons.push(
|
||||||
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
|
<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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function inViewMode(pathname: string): boolean {
|
||||||
|
return pathname.endsWith('/view');
|
||||||
|
}
|
||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
wrapper: css`
|
wrapper: css`
|
||||||
padding: ${theme.spacing(2)} 0;
|
padding: ${theme.spacing(2)} 0;
|
||||||
|
@ -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;
|
||||||
|
`,
|
||||||
|
});
|
@ -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};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
@ -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;
|
||||||
|
`,
|
||||||
|
});
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
116
public/app/features/alerting/unified/hooks/useCombinedRule.ts
Normal file
116
public/app/features/alerting/unified/hooks/useCombinedRule.ts
Normal 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;
|
||||||
|
}
|
@ -24,7 +24,7 @@ interface CacheValue {
|
|||||||
result: CombinedRuleNamespace[];
|
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
|
// can limit to a single rules source
|
||||||
export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRuleNamespace[] {
|
export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRuleNamespace[] {
|
||||||
const promRulesResponses = useUnifiedAlertingSelector((state) => state.promRules);
|
const promRulesResponses = useUnifiedAlertingSelector((state) => state.promRules);
|
||||||
|
@ -38,16 +38,15 @@ import { makeAMLink } from '../utils/misc';
|
|||||||
import { withAppEvents, withSerializedError } from '../utils/redux';
|
import { withAppEvents, withSerializedError } from '../utils/redux';
|
||||||
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||||
import {
|
import {
|
||||||
getRuleIdentifier,
|
isCloudRuleIdentifier,
|
||||||
hashRulerRule,
|
|
||||||
isGrafanaRuleIdentifier,
|
isGrafanaRuleIdentifier,
|
||||||
isGrafanaRulerRule,
|
isGrafanaRulerRule,
|
||||||
|
isPrometheusRuleIdentifier,
|
||||||
isRulerNotSupportedResponse,
|
isRulerNotSupportedResponse,
|
||||||
ruleWithLocationToRuleIdentifier,
|
|
||||||
stringifyRuleIdentifier,
|
|
||||||
} from '../utils/rules';
|
} from '../utils/rules';
|
||||||
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
|
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import * as ruleId from '../utils/rule-id';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
export const fetchPromRulesAction = createAsyncThunk(
|
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)) {
|
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
||||||
const namespaces = await fetchRulerRules(GRAFANA_RULES_SOURCE_NAME);
|
const namespaces = await fetchRulerRules(GRAFANA_RULES_SOURCE_NAME);
|
||||||
// find namespace and group that contains the uid for the rule
|
// find namespace and group that contains the uid for the rule
|
||||||
@ -141,12 +140,25 @@ async function findExistingRule(ruleIdentifier: RuleIdentifier): Promise<RuleWit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
const { ruleSourceName, namespace, groupName, ruleHash } = ruleIdentifier;
|
|
||||||
|
if (isCloudRuleIdentifier(ruleIdentifier)) {
|
||||||
|
const { ruleSourceName, namespace, groupName } = ruleIdentifier;
|
||||||
const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName);
|
const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName);
|
||||||
if (group) {
|
|
||||||
const rule = group.rules.find((rule) => hashRulerRule(rule) === ruleHash);
|
if (!group) {
|
||||||
if (rule) {
|
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 {
|
return {
|
||||||
group,
|
group,
|
||||||
ruleSourceName,
|
ruleSourceName,
|
||||||
@ -154,15 +166,18 @@ async function findExistingRule(ruleIdentifier: RuleIdentifier): Promise<RuleWit
|
|||||||
rule,
|
rule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPrometheusRuleIdentifier(ruleIdentifier)) {
|
||||||
|
throw new Error('Native prometheus rules can not be edited in grafana.');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchExistingRuleAction = createAsyncThunk(
|
export const fetchEditableRuleAction = createAsyncThunk(
|
||||||
'unifiedalerting/fetchExistingRule',
|
'unifiedalerting/fetchEditableRule',
|
||||||
(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> =>
|
(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> =>
|
||||||
withSerializedError(findExistingRule(ruleIdentifier))
|
withSerializedError(findEditableRule(ruleIdentifier))
|
||||||
);
|
);
|
||||||
|
|
||||||
async function deleteRule(ruleWithLocation: RuleWithLocation): Promise<void> {
|
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+
|
* fetch the rules group from backend, delete group if it is found and+
|
||||||
* reload ruler rules
|
* reload ruler rules
|
||||||
@ -193,7 +211,7 @@ export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult<vo
|
|||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
withAppEvents(
|
withAppEvents(
|
||||||
(async () => {
|
(async () => {
|
||||||
const ruleWithLocation = await findExistingRule(ruleIdentifier);
|
const ruleWithLocation = await findEditableRule(ruleIdentifier);
|
||||||
if (!ruleWithLocation) {
|
if (!ruleWithLocation) {
|
||||||
throw new Error('Rule not found.');
|
throw new Error('Rule not found.');
|
||||||
}
|
}
|
||||||
@ -201,6 +219,10 @@ export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult<vo
|
|||||||
// refetch rules for this rules source
|
// refetch rules for this rules source
|
||||||
dispatch(fetchRulerRulesAction(ruleWithLocation.ruleSourceName));
|
dispatch(fetchRulerRulesAction(ruleWithLocation.ruleSourceName));
|
||||||
dispatch(fetchPromRulesAction(ruleWithLocation.ruleSourceName));
|
dispatch(fetchPromRulesAction(ruleWithLocation.ruleSourceName));
|
||||||
|
|
||||||
|
if (options.navigateTo) {
|
||||||
|
locationService.replace(options.navigateTo);
|
||||||
|
}
|
||||||
})(),
|
})(),
|
||||||
{
|
{
|
||||||
successMessage: 'Rule deleted.',
|
successMessage: 'Rule deleted.',
|
||||||
@ -216,7 +238,7 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
|
|||||||
// if we're updating a rule...
|
// if we're updating a rule...
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// refetch it so we always have the latest greatest
|
// refetch it so we always have the latest greatest
|
||||||
const freshExisting = await findExistingRule(ruleWithLocationToRuleIdentifier(existing));
|
const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing));
|
||||||
if (!freshExisting) {
|
if (!freshExisting) {
|
||||||
throw new Error('Rule not found.');
|
throw new Error('Rule not found.');
|
||||||
}
|
}
|
||||||
@ -232,7 +254,7 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
await setRulerRuleGroup(dataSourceName, namespace, payload);
|
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);
|
await setRulerRuleGroup(dataSourceName, namespace, payload);
|
||||||
return getRuleIdentifier(dataSourceName, namespace, group, formRule);
|
return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Data source and location must be specified');
|
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...
|
// updating an existing rule...
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// refetch it to be sure we have the latest
|
// refetch it to be sure we have the latest
|
||||||
const freshExisting = await findExistingRule(ruleWithLocationToRuleIdentifier(existing));
|
const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing));
|
||||||
if (!freshExisting) {
|
if (!freshExisting) {
|
||||||
throw new Error('Rule not found.');
|
throw new Error('Rule not found.');
|
||||||
}
|
}
|
||||||
@ -345,7 +367,7 @@ export const saveRuleFormAction = createAsyncThunk(
|
|||||||
locationService.push(redirectOnSave);
|
locationService.push(redirectOnSave);
|
||||||
} else {
|
} else {
|
||||||
// redirect to edit page
|
// redirect to edit page
|
||||||
const newLocation = `/alerting/${encodeURIComponent(stringifyRuleIdentifier(identifier))}/edit`;
|
const newLocation = `/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`;
|
||||||
if (locationService.getLocation().pathname !== newLocation) {
|
if (locationService.getLocation().pathname !== newLocation) {
|
||||||
locationService.replace(newLocation);
|
locationService.replace(newLocation);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
|
|||||||
import {
|
import {
|
||||||
fetchAlertManagerConfigAction,
|
fetchAlertManagerConfigAction,
|
||||||
fetchAmAlertsAction,
|
fetchAmAlertsAction,
|
||||||
fetchExistingRuleAction,
|
fetchEditableRuleAction,
|
||||||
fetchGrafanaNotifiersAction,
|
fetchGrafanaNotifiersAction,
|
||||||
fetchPromRulesAction,
|
fetchPromRulesAction,
|
||||||
fetchRulerRulesAction,
|
fetchRulerRulesAction,
|
||||||
@ -26,7 +26,7 @@ export const reducer = combineReducers({
|
|||||||
.reducer,
|
.reducer,
|
||||||
ruleForm: combineReducers({
|
ruleForm: combineReducers({
|
||||||
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
|
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
|
||||||
existingRule: createAsyncSlice('existingRule', fetchExistingRuleAction).reducer,
|
existingRule: createAsyncSlice('existingRule', fetchEditableRuleAction).reducer,
|
||||||
}),
|
}),
|
||||||
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
|
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
|
||||||
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
|
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
|
||||||
|
@ -61,9 +61,7 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings<Da
|
|||||||
return getAllDataSources().find((source) => source.name === name);
|
return getAllDataSources().find((source) => source.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRulesSourceByName(
|
export function getRulesSourceByName(name: string): RulesSource | undefined {
|
||||||
name: string
|
|
||||||
): DataSourceInstanceSettings<DataSourceJsonData> | typeof GRAFANA_RULES_SOURCE_NAME | undefined {
|
|
||||||
if (name === GRAFANA_RULES_SOURCE_NAME) {
|
if (name === GRAFANA_RULES_SOURCE_NAME) {
|
||||||
return GRAFANA_RULES_SOURCE_NAME;
|
return GRAFANA_RULES_SOURCE_NAME;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,17 @@
|
|||||||
import { urlUtil, UrlQueryMap } from '@grafana/data';
|
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 { 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) {
|
export function createExploreLink(dataSourceName: string, query: string) {
|
||||||
return urlUtil.renderUrl('explore', {
|
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> {
|
export function arrayToRecord(items: Array<{ key: string; value: string }>): Record<string, string> {
|
||||||
return items.reduce<Record<string, string>>((rec, { key, value }) => {
|
return items.reduce<Record<string, string>>((rec, { key, value }) => {
|
||||||
rec[key] = value;
|
rec[key] = value;
|
||||||
|
118
public/app/features/alerting/unified/utils/query.test.ts
Normal file
118
public/app/features/alerting/unified/utils/query.test.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
67
public/app/features/alerting/unified/utils/query.ts
Normal file
67
public/app/features/alerting/unified/utils/query.ts
Normal 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.`);
|
||||||
|
}
|
||||||
|
}
|
213
public/app/features/alerting/unified/utils/rule-id.ts
Normal file
213
public/app/features/alerting/unified/utils/rule-id.ts
Normal 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])));
|
||||||
|
}
|
@ -1,7 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Annotations,
|
|
||||||
GrafanaAlertState,
|
GrafanaAlertState,
|
||||||
Labels,
|
|
||||||
PromAlertingRuleState,
|
PromAlertingRuleState,
|
||||||
PromRuleType,
|
PromRuleType,
|
||||||
RulerAlertingRuleDTO,
|
RulerAlertingRuleDTO,
|
||||||
@ -14,33 +12,32 @@ import {
|
|||||||
AlertingRule,
|
AlertingRule,
|
||||||
CloudRuleIdentifier,
|
CloudRuleIdentifier,
|
||||||
GrafanaRuleIdentifier,
|
GrafanaRuleIdentifier,
|
||||||
|
PrometheusRuleIdentifier,
|
||||||
PromRuleWithLocation,
|
PromRuleWithLocation,
|
||||||
RecordingRule,
|
RecordingRule,
|
||||||
Rule,
|
Rule,
|
||||||
RuleIdentifier,
|
RuleIdentifier,
|
||||||
RuleNamespace,
|
RuleNamespace,
|
||||||
RuleWithLocation,
|
|
||||||
} from 'app/types/unified-alerting';
|
} from 'app/types/unified-alerting';
|
||||||
import { AsyncRequestState } from './redux';
|
import { AsyncRequestState } from './redux';
|
||||||
import { RULER_NOT_SUPPORTED_MSG } from './constants';
|
import { RULER_NOT_SUPPORTED_MSG } from './constants';
|
||||||
import { hash } from './misc';
|
|
||||||
import { capitalize } from 'lodash';
|
import { capitalize } from 'lodash';
|
||||||
import { State } from '../components/StateTag';
|
import { State } from '../components/StateTag';
|
||||||
|
|
||||||
export function isAlertingRule(rule: Rule): rule is AlertingRule {
|
export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule {
|
||||||
return rule.type === PromRuleType.Alerting;
|
return typeof rule === 'object' && rule.type === PromRuleType.Alerting;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRecordingRule(rule: Rule): rule is RecordingRule {
|
export function isRecordingRule(rule: Rule): rule is RecordingRule {
|
||||||
return rule.type === PromRuleType.Recording;
|
return rule.type === PromRuleType.Recording;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAlertingRulerRule(rule: RulerRuleDTO): rule is RulerAlertingRuleDTO {
|
export function isAlertingRulerRule(rule?: RulerRuleDTO): rule is RulerAlertingRuleDTO {
|
||||||
return 'alert' in rule;
|
return typeof rule === 'object' && 'alert' in rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRecordingRulerRule(rule: RulerRuleDTO): rule is RulerRecordingRuleDTO {
|
export function isRecordingRulerRule(rule?: RulerRuleDTO): rule is RulerRecordingRuleDTO {
|
||||||
return 'record' in rule;
|
return typeof rule === 'object' && 'record' in rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGrafanaRulerRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
|
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;
|
return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
|
export function isGrafanaRuleIdentifier(identifier: RuleIdentifier): identifier is GrafanaRuleIdentifier {
|
||||||
return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0])));
|
return 'uid' in identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is used to identify lotex rules, as they do not have a unique identifier
|
export function isCloudRuleIdentifier(identifier: RuleIdentifier): identifier is CloudRuleIdentifier {
|
||||||
export function hashRulerRule(rule: RulerRuleDTO): number {
|
return 'rulerRuleHash' in identifier;
|
||||||
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 isGrafanaRuleIdentifier(location: RuleIdentifier): location is GrafanaRuleIdentifier {
|
export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifier is PrometheusRuleIdentifier {
|
||||||
return 'uid' in location;
|
return 'ruleHash' in identifier;
|
||||||
}
|
|
||||||
|
|
||||||
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 alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState): string {
|
export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState): string {
|
||||||
|
@ -448,6 +448,21 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
|
() => 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',
|
path: '/playlists',
|
||||||
component: SafeDynamicImport(
|
component: SafeDynamicImport(
|
||||||
|
@ -112,16 +112,23 @@ export interface CloudRuleIdentifier {
|
|||||||
ruleSourceName: string;
|
ruleSourceName: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
ruleHash: number;
|
rulerRuleHash: number;
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuleFilterState {
|
|
||||||
queryString?: string;
|
|
||||||
dataSource?: string;
|
|
||||||
alertState?: string;
|
|
||||||
}
|
}
|
||||||
export interface GrafanaRuleIdentifier {
|
export interface GrafanaRuleIdentifier {
|
||||||
uid: string;
|
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;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user