mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Optimize rule details page data fetching (#72977)
This commit is contained in:
parent
15ac12637d
commit
df3d10606d
@ -217,6 +217,7 @@ describe('CloneRuleEditor', function () {
|
||||
ruleSourceName: 'my-prom-ds',
|
||||
namespace: 'namespace-one',
|
||||
groupName: 'group1',
|
||||
ruleName: 'First Ruler Rule',
|
||||
rulerRuleHash: hashRulerRule(originRule),
|
||||
}}
|
||||
/>,
|
||||
|
@ -11,7 +11,6 @@ import { PromRuleType } from '../../../types/unified-alerting-dto';
|
||||
|
||||
import { RedirectToRuleViewer } from './RedirectToRuleViewer';
|
||||
import * as combinedRuleHooks from './hooks/useCombinedRule';
|
||||
import { useCombinedRulesMatching } from './hooks/useCombinedRule';
|
||||
import { getRulesSourceByName } from './utils/datasource';
|
||||
|
||||
jest.mock('./hooks/useCombinedRule');
|
||||
@ -23,8 +22,8 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
jest.mock('react-use');
|
||||
|
||||
const renderRedirectToRuleViewer = (pathname: string) => {
|
||||
jest.mocked(useLocation).mockReturnValue({ pathname, trigger: '' });
|
||||
const renderRedirectToRuleViewer = (pathname: string, search?: string) => {
|
||||
jest.mocked(useLocation).mockReturnValue({ pathname, trigger: '', search });
|
||||
|
||||
locationService.push(pathname);
|
||||
|
||||
@ -50,11 +49,9 @@ const mockRuleSourceByName = () => {
|
||||
|
||||
describe('Redirect to Rule viewer', () => {
|
||||
it('should list rules that match the same name', () => {
|
||||
jest.mocked(useCombinedRulesMatching).mockReturnValue({
|
||||
result: mockedRules,
|
||||
jest.mocked(combinedRuleHooks.useCloudCombinedRulesMatching).mockReturnValue({
|
||||
rules: mockedRules,
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
requestId: 'A',
|
||||
error: undefined,
|
||||
});
|
||||
mockRuleSourceByName();
|
||||
@ -63,11 +60,9 @@ describe('Redirect to Rule viewer', () => {
|
||||
});
|
||||
|
||||
it('should redirect to view rule page if only one match', () => {
|
||||
jest.mocked(useCombinedRulesMatching).mockReturnValue({
|
||||
result: [mockedRules[0]],
|
||||
jest.mocked(combinedRuleHooks.useCloudCombinedRulesMatching).mockReturnValue({
|
||||
rules: [mockedRules[0]],
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
requestId: 'A',
|
||||
error: undefined,
|
||||
});
|
||||
mockRuleSourceByName();
|
||||
@ -76,38 +71,71 @@ describe('Redirect to Rule viewer', () => {
|
||||
});
|
||||
|
||||
it('should properly decode rule name', () => {
|
||||
const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCombinedRulesMatching').mockReturnValue({
|
||||
result: [mockedRules[0]],
|
||||
const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCloudCombinedRulesMatching').mockReturnValue({
|
||||
rules: [mockedRules[0]],
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
requestId: 'A',
|
||||
error: undefined,
|
||||
});
|
||||
mockRuleSourceByName();
|
||||
|
||||
const ruleName = 'cloud rule++ !@#$%^&*()-/?';
|
||||
|
||||
renderRedirectToRuleViewer(`/alerting/prom-db/${encodeURIComponent(ruleName)}/find`);
|
||||
|
||||
expect(rulesMatchingSpy).toHaveBeenCalledWith(ruleName, 'prom-db');
|
||||
expect(rulesMatchingSpy).toHaveBeenCalledWith(ruleName, 'prom-db', { groupName: undefined, namespace: undefined });
|
||||
expect(screen.getByText('Redirected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should properly decode source name', () => {
|
||||
const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCombinedRulesMatching').mockReturnValue({
|
||||
result: [mockedRules[0]],
|
||||
it('should apply additional group name and namespace filters', () => {
|
||||
const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCloudCombinedRulesMatching').mockReturnValue({
|
||||
rules: [mockedRules[0]],
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
requestId: 'A',
|
||||
error: undefined,
|
||||
});
|
||||
mockRuleSourceByName();
|
||||
|
||||
const ruleName = 'prom alert';
|
||||
const dsName = 'test prom';
|
||||
const group = 'foo';
|
||||
const namespace = 'bar';
|
||||
|
||||
renderRedirectToRuleViewer(`/alerting/${dsName}/${ruleName}/find`, `?group=${group}&namespace=${namespace}`);
|
||||
expect(rulesMatchingSpy).toHaveBeenCalledWith(ruleName, dsName, {
|
||||
groupName: group,
|
||||
namespace: namespace,
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly decode source name', () => {
|
||||
const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCloudCombinedRulesMatching').mockReturnValue({
|
||||
rules: [mockedRules[0]],
|
||||
loading: false,
|
||||
error: undefined,
|
||||
});
|
||||
mockRuleSourceByName();
|
||||
|
||||
const sourceName = 'prom<|>++ !@#$%^&*()-/?';
|
||||
|
||||
renderRedirectToRuleViewer(`/alerting/${encodeURIComponent(sourceName)}/prom alert/find`);
|
||||
|
||||
expect(rulesMatchingSpy).toHaveBeenCalledWith('prom alert', sourceName);
|
||||
expect(rulesMatchingSpy).toHaveBeenCalledWith('prom alert', sourceName, {
|
||||
groupName: undefined,
|
||||
namespace: undefined,
|
||||
});
|
||||
expect(screen.getByText('Redirected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error when datasource does not exist', () => {
|
||||
jest.mocked(getRulesSourceByName).mockReturnValueOnce(undefined);
|
||||
jest.mocked(combinedRuleHooks.useCloudCombinedRulesMatching).mockReturnValue({
|
||||
rules: [],
|
||||
loading: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderRedirectToRuleViewer(`/alerting/does-not-exist/prom alert/find`);
|
||||
expect(screen.getByText('Could not find data source with name: does-not-exist.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const mockedRules: CombinedRule[] = [
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useLocation } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { config, isFetchError } from '@grafana/runtime';
|
||||
import { Alert, Card, Icon, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
|
||||
import { AlertLabels } from './components/AlertLabels';
|
||||
import { RuleViewerLayout } from './components/rule-viewer/RuleViewerLayout';
|
||||
import { useCombinedRulesMatching } from './hooks/useCombinedRule';
|
||||
import { useCloudCombinedRulesMatching } from './hooks/useCombinedRule';
|
||||
import { getRulesSourceByName } from './utils/datasource';
|
||||
import { createViewLink } from './utils/misc';
|
||||
|
||||
@ -24,35 +24,55 @@ function useRuleFindParams() {
|
||||
// Relevant issue: https://github.com/remix-run/history/issues/505#issuecomment-453175833
|
||||
// It was probably fixed in React-Router v6
|
||||
const location = useLocation();
|
||||
const segments = location.pathname?.replace(subUrl, '').split('/') ?? []; // ["", "alerting", "{sourceName}", "{name}]
|
||||
|
||||
const name = decodeURIComponent(segments[3]);
|
||||
const sourceName = decodeURIComponent(segments[2]);
|
||||
return useMemo(() => {
|
||||
const segments = location.pathname?.replace(subUrl, '').split('/') ?? []; // ["", "alerting", "{sourceName}", "{name}]
|
||||
|
||||
return { name, sourceName };
|
||||
const name = decodeURIComponent(segments[3]);
|
||||
const sourceName = decodeURIComponent(segments[2]);
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
return {
|
||||
name,
|
||||
sourceName,
|
||||
namespace: searchParams.get('namespace') ?? undefined,
|
||||
group: searchParams.get('group') ?? undefined,
|
||||
};
|
||||
}, [location]);
|
||||
}
|
||||
|
||||
export function RedirectToRuleViewer(): JSX.Element | null {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { name, sourceName } = useRuleFindParams();
|
||||
const { error, loading, result: rules, dispatched } = useCombinedRulesMatching(name, sourceName);
|
||||
const { name, sourceName, namespace, group } = useRuleFindParams();
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
rules = [],
|
||||
} = useCloudCombinedRulesMatching(name, sourceName, { namespace, groupName: group });
|
||||
|
||||
if (!name || !sourceName) {
|
||||
return <Redirect to="/notfound" />;
|
||||
}
|
||||
|
||||
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>
|
||||
{isFetchError(error) && (
|
||||
<details className={styles.errorMessage}>
|
||||
{error.message}
|
||||
<br />
|
||||
{/* {!!error?.stack && error.stack} */}
|
||||
</details>
|
||||
)}
|
||||
</Alert>
|
||||
</RuleViewerLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !dispatched || !Array.isArray(rules)) {
|
||||
if (loading) {
|
||||
return (
|
||||
<RuleViewerLayout title={pageTitle}>
|
||||
<LoadingPlaceholder text="Loading rule..." />
|
||||
@ -60,10 +80,6 @@ export function RedirectToRuleViewer(): JSX.Element | null {
|
||||
);
|
||||
}
|
||||
|
||||
if (!name || !sourceName) {
|
||||
return <Redirect to="/notfound" />;
|
||||
}
|
||||
|
||||
const rulesSource = getRulesSourceByName(sourceName);
|
||||
|
||||
if (!rulesSource) {
|
||||
|
@ -1,16 +1,18 @@
|
||||
import { RelativeTimeRange } from '@grafana/data';
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting';
|
||||
import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import {
|
||||
AlertQuery,
|
||||
Annotations,
|
||||
GrafanaAlertStateDecision,
|
||||
Labels,
|
||||
PromRulesResponse,
|
||||
RulerRuleGroupDTO,
|
||||
RulerRulesConfigDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { Folder } from '../components/rule-editor/RuleFolderPicker';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import { arrayKeyValuesToObject } from '../utils/labels';
|
||||
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
|
||||
|
||||
@ -21,6 +23,7 @@ import {
|
||||
paramsWithMatcherAndState,
|
||||
prepareRulesFilterQueryParams,
|
||||
} from './prometheus';
|
||||
import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler';
|
||||
|
||||
export type ResponseLabels = {
|
||||
labels: AlertInstances[];
|
||||
@ -132,6 +135,49 @@ export const alertRuleApi = alertingApi.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
|
||||
prometheusRuleNamespaces: build.query<
|
||||
RuleNamespace[],
|
||||
{ ruleSourceName: string; namespace?: string; groupName?: string; ruleName?: string }
|
||||
>({
|
||||
query: ({ ruleSourceName, namespace, groupName, ruleName }) => {
|
||||
const queryParams: Record<string, string | undefined> = {};
|
||||
// if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) {
|
||||
queryParams['file'] = namespace;
|
||||
queryParams['rule_group'] = groupName;
|
||||
queryParams['rule_name'] = ruleName;
|
||||
// }
|
||||
|
||||
return {
|
||||
url: `api/prometheus/${getDatasourceAPIUid(ruleSourceName)}/api/v1/rules`,
|
||||
params: queryParams,
|
||||
};
|
||||
},
|
||||
transformResponse: (response: PromRulesResponse, _, args): RuleNamespace[] => {
|
||||
return groupRulesByFileName(response.data.groups, args.ruleSourceName);
|
||||
},
|
||||
}),
|
||||
|
||||
rulerRules: build.query<
|
||||
RulerRulesConfigDTO,
|
||||
{ rulerConfig: RulerDataSourceConfig; filter?: FetchRulerRulesFilter }
|
||||
>({
|
||||
query: ({ rulerConfig, filter }) => {
|
||||
const { path, params } = rulerUrlBuilder(rulerConfig).rules(filter);
|
||||
return { url: path, params };
|
||||
},
|
||||
}),
|
||||
|
||||
// TODO This should be probably a separate ruler API file
|
||||
rulerRuleGroup: build.query<
|
||||
RulerRuleGroupDTO,
|
||||
{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }
|
||||
>({
|
||||
query: ({ rulerConfig, namespace, group }) => {
|
||||
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
|
||||
return { url: path, params };
|
||||
},
|
||||
}),
|
||||
|
||||
exportRule: build.query<string, { uid: string; format: 'yaml' | 'json' }>({
|
||||
query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format) }),
|
||||
}),
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { AlertmanagerApiFeatures } from '../../../../types/unified-alerting-dto';
|
||||
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto';
|
||||
import { withPerformanceLogging } from '../Analytics';
|
||||
import { getRulesDataSource } from '../utils/datasource';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
import { discoverAlertmanagerFeatures } from './buildInfo';
|
||||
import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo';
|
||||
|
||||
export const featureDiscoveryApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
@ -15,5 +19,34 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({
|
||||
queryFn: async ({ rulesSourceName }) => {
|
||||
const dsSettings = getRulesDataSource(rulesSourceName);
|
||||
if (!dsSettings) {
|
||||
return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) };
|
||||
}
|
||||
|
||||
const discoverFeaturesWithLogging = withPerformanceLogging(
|
||||
discoverFeatures,
|
||||
`[${rulesSourceName}] Rules source features discovered`,
|
||||
{
|
||||
dataSourceName: rulesSourceName,
|
||||
endpoint: 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures',
|
||||
}
|
||||
);
|
||||
|
||||
const dsFeatures = await discoverFeaturesWithLogging(dsSettings.name);
|
||||
|
||||
const rulerConfig: RulerDataSourceConfig | undefined = dsFeatures.features.rulerApiEnabled
|
||||
? {
|
||||
dataSourceName: dsSettings.name,
|
||||
apiVersion: dsFeatures.application === PromApplication.Cortex ? 'legacy' : 'config',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { data: { rulerConfig } };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
@ -1,36 +1,53 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
import { byRole, byText } from 'testing-library-selector';
|
||||
|
||||
import { locationService, setBackendSrv } from '@grafana/runtime';
|
||||
import { config, locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { useCombinedRule } from '../../hooks/useCombinedRule';
|
||||
import { discoverFeatures } from '../../api/buildInfo';
|
||||
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
|
||||
import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks';
|
||||
import { mockAlertRuleApi, setupMswServer } from '../../mockApi';
|
||||
import {
|
||||
getCloudRule,
|
||||
getGrafanaRule,
|
||||
grantUserPermissions,
|
||||
mockDataSource,
|
||||
MockDataSourceSrv,
|
||||
mockPromAlertingRule,
|
||||
mockRulerAlertingRule,
|
||||
promRuleFromRulerRule,
|
||||
} from '../../mocks';
|
||||
import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi';
|
||||
import { mockPluginSettings } from '../../mocks/plugins';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
|
||||
import { RuleViewer } from './RuleViewer.v1';
|
||||
|
||||
const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' });
|
||||
const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' }, { uid: 'test1', title: 'Test alert' });
|
||||
const mockCloudRule = getCloudRule({ name: 'cloud test alert' });
|
||||
const mockRoute: GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> = {
|
||||
|
||||
const mockRoute = (id?: string): GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> => ({
|
||||
route: {
|
||||
path: '/',
|
||||
component: RuleViewer,
|
||||
},
|
||||
queryParams: { returnTo: '/alerting/list' },
|
||||
match: { params: { id: 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' },
|
||||
match: { params: { id: id ?? 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' },
|
||||
history: locationService.getHistory(),
|
||||
location: { pathname: '', hash: '', search: '', state: '' },
|
||||
staticContext: {},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../hooks/useCombinedRule');
|
||||
// jest.mock('../../hooks/useCombinedRule');
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getDataSourceSrv: () => {
|
||||
@ -44,14 +61,11 @@ jest.mock('@grafana/runtime', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const renderRuleViewer = () => {
|
||||
return act(async () => {
|
||||
render(
|
||||
<TestProvider>
|
||||
<RuleViewer {...mockRoute} />
|
||||
</TestProvider>
|
||||
);
|
||||
});
|
||||
jest.mock('../../hooks/useIsRuleEditable');
|
||||
jest.mock('../../api/buildInfo');
|
||||
|
||||
const mocks = {
|
||||
useIsRuleEditable: jest.mocked(useIsRuleEditable),
|
||||
};
|
||||
|
||||
const ui = {
|
||||
@ -61,36 +75,96 @@ const ui = {
|
||||
delete: byRole('button', { name: /delete/i }),
|
||||
silence: byRole('link', { name: 'Silence' }),
|
||||
},
|
||||
loadingIndicator: byText(/Loading rule/i),
|
||||
};
|
||||
jest.mock('../../hooks/useIsRuleEditable');
|
||||
|
||||
const mocks = {
|
||||
useIsRuleEditable: jest.mocked(useIsRuleEditable),
|
||||
const renderRuleViewer = async (ruleId?: string) => {
|
||||
render(
|
||||
<TestProvider>
|
||||
<RuleViewer {...mockRoute(ruleId)} />
|
||||
</TestProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
};
|
||||
|
||||
const server = setupMswServer();
|
||||
|
||||
const dsName = 'prometheus';
|
||||
const rulerRule = mockRulerAlertingRule({ alert: 'cloud test alert' });
|
||||
const rulerRuleIdentifier = ruleId.fromRulerRule('prometheus', 'ns-default', 'group-default', rulerRule);
|
||||
|
||||
beforeAll(() => {
|
||||
setBackendSrv(backendSrv);
|
||||
|
||||
// some action buttons need to check what Alertmanager setup we have for Grafana managed rules
|
||||
mockAlertmanagerChoiceResponse(server, {
|
||||
alertmanagersChoice: AlertmanagerChoice.Internal,
|
||||
numExternalAlertmanagers: 1,
|
||||
});
|
||||
// we need to mock this one for the "declare incident" button
|
||||
mockPluginSettings(server, SupportedPlugin.Incident);
|
||||
|
||||
const dsSettings = mockDataSource({
|
||||
name: dsName,
|
||||
uid: dsName,
|
||||
});
|
||||
config.datasources = {
|
||||
[dsName]: dsSettings,
|
||||
};
|
||||
|
||||
setDataSourceSrv(new MockDataSourceSrv({ [dsName]: dsSettings }));
|
||||
|
||||
mockAlertRuleApi(server).rulerRules('grafana', {
|
||||
[mockGrafanaRule.namespace.name]: [
|
||||
{ name: mockGrafanaRule.group.name, interval: '1m', rules: [mockGrafanaRule.rulerRule!] },
|
||||
],
|
||||
});
|
||||
|
||||
const { name, query, labels, annotations } = mockGrafanaRule;
|
||||
mockAlertRuleApi(server).prometheusRuleNamespaces('grafana', {
|
||||
data: {
|
||||
groups: [
|
||||
{
|
||||
file: mockGrafanaRule.namespace.name,
|
||||
interval: 60,
|
||||
name: mockGrafanaRule.group.name,
|
||||
rules: [mockPromAlertingRule({ name, query, labels, annotations })],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
mockAlertRuleApi(server).rulerRuleGroup(dsName, 'ns-default', 'group-default', {
|
||||
name: 'group-default',
|
||||
interval: '1m',
|
||||
rules: [rulerRule],
|
||||
});
|
||||
|
||||
mockAlertRuleApi(server).prometheusRuleNamespaces(dsName, {
|
||||
data: {
|
||||
groups: [
|
||||
{
|
||||
file: 'ns-default',
|
||||
interval: 60,
|
||||
name: 'group-default',
|
||||
rules: [promRuleFromRulerRule(rulerRule, { state: PromAlertingRuleState.Inactive })],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleViewer', () => {
|
||||
let mockCombinedRule: jest.MockedFn<typeof useCombinedRule>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCombinedRule = jest.mocked(useCombinedRule);
|
||||
});
|
||||
let mockCombinedRule = jest.fn();
|
||||
|
||||
afterEach(() => {
|
||||
mockCombinedRule.mockReset();
|
||||
});
|
||||
|
||||
it('should render page with grafana alert', async () => {
|
||||
mockCombinedRule.mockReturnValue({
|
||||
result: mockGrafanaRule as CombinedRule,
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
requestId: 'A',
|
||||
error: undefined,
|
||||
});
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||
await renderRuleViewer();
|
||||
|
||||
@ -98,15 +172,14 @@ describe('RuleViewer', () => {
|
||||
});
|
||||
|
||||
it('should render page with cloud alert', async () => {
|
||||
mockCombinedRule.mockReturnValue({
|
||||
result: mockCloudRule as CombinedRule,
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
requestId: 'A',
|
||||
error: undefined,
|
||||
});
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
|
||||
jest
|
||||
.mocked(discoverFeatures)
|
||||
.mockResolvedValue({ application: PromApplication.Mimir, features: { rulerApiEnabled: true } });
|
||||
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||
await renderRuleViewer();
|
||||
await renderRuleViewer(ruleId.stringifyIdentifier(rulerRuleIdentifier));
|
||||
|
||||
expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument();
|
||||
});
|
||||
@ -114,10 +187,10 @@ describe('RuleViewer', () => {
|
||||
|
||||
describe('RuleDetails RBAC', () => {
|
||||
describe('Grafana rules action buttons in details', () => {
|
||||
let mockCombinedRule: jest.MockedFn<typeof useCombinedRule>;
|
||||
let mockCombinedRule = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockCombinedRule = jest.mocked(useCombinedRule);
|
||||
// mockCombinedRule = jest.mocked(useCombinedRule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -141,7 +214,7 @@ describe('RuleDetails RBAC', () => {
|
||||
expect(ui.actionButtons.edit.get()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Delete button for users with the delete permission', async () => {
|
||||
it('Should render Delete button for users with the delete permission', async () => {
|
||||
// Arrange
|
||||
mockCombinedRule.mockReturnValue({
|
||||
result: mockGrafanaRule as CombinedRule,
|
||||
@ -174,7 +247,9 @@ describe('RuleDetails RBAC', () => {
|
||||
await renderRuleViewer();
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.silence.query()).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(ui.actionButtons.silence.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render Silence button for users with the instance create permissions', async () => {
|
||||
@ -194,7 +269,9 @@ describe('RuleDetails RBAC', () => {
|
||||
await renderRuleViewer();
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.silence.query()).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(ui.actionButtons.silence.query()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render clone button for users having create rule permission', async () => {
|
||||
@ -228,10 +305,10 @@ describe('RuleDetails RBAC', () => {
|
||||
});
|
||||
});
|
||||
describe('Cloud rules action buttons', () => {
|
||||
let mockCombinedRule: jest.MockedFn<typeof useCombinedRule>;
|
||||
let mockCombinedRule = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockCombinedRule = jest.mocked(useCombinedRule);
|
||||
// mockCombinedRule = jest.mocked(useCombinedRule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -5,7 +5,7 @@ import { useObservable, useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
|
||||
import { Stack } from '@grafana/experimental';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { config, isFetchError } from '@grafana/runtime';
|
||||
import { Alert, Button, Collapse, Icon, IconButton, LoadingPlaceholder, useStyles2, VerticalGroup } from '@grafana/ui';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
@ -44,9 +44,16 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
||||
const [expandQuery, setExpandQuery] = useToggle(false);
|
||||
|
||||
const { id } = match.params;
|
||||
const identifier = ruleId.tryParse(id, true);
|
||||
const identifier = useMemo(() => {
|
||||
if (!id) {
|
||||
throw new Error('Rule ID is required');
|
||||
}
|
||||
|
||||
return ruleId.parse(id, true);
|
||||
}, [id]);
|
||||
|
||||
const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
|
||||
|
||||
const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
|
||||
const runner = useMemo(() => new AlertingQueryRunner(), []);
|
||||
const data = useObservable(runner.get());
|
||||
const queries = useMemo(() => alertRuleToQueries(rule), [rule]);
|
||||
@ -120,9 +127,10 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
||||
return (
|
||||
<Alert title={errorTitle}>
|
||||
<details className={styles.errorMessage}>
|
||||
{error?.message ?? errorMessage}
|
||||
{isFetchError(error) ? error.message : errorMessage}
|
||||
<br />
|
||||
{!!error?.stack && error.stack}
|
||||
{/* TODO Fix typescript */}
|
||||
{/* {error && error?.stack} */}
|
||||
</details>
|
||||
</Alert>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { Stack } from '@grafana/experimental';
|
||||
import { Alert, Button, Icon, LoadingPlaceholder, Tab, TabContent, TabsBar, Text } from '@grafana/ui';
|
||||
@ -35,10 +35,17 @@ enum Tabs {
|
||||
// add provisioning and federation stuff back in
|
||||
const RuleViewer = ({ match }: RuleViewerProps) => {
|
||||
const { id } = match.params;
|
||||
const identifier = ruleId.tryParse(id, true);
|
||||
const [activeTab, setActiveTab] = useState<Tabs>(Tabs.Instances);
|
||||
|
||||
const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
|
||||
const identifier = useMemo(() => {
|
||||
if (!id) {
|
||||
throw new Error('Rule ID is required');
|
||||
}
|
||||
|
||||
return ruleId.parse(id, true);
|
||||
}, [id]);
|
||||
|
||||
const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
|
||||
|
||||
// we're setting the document title and the breadcrumb manually
|
||||
useRuleViewerPageTitle(rule);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
@ -33,7 +33,7 @@ describe('RuleListGroupView', () => {
|
||||
describe('RBAC', () => {
|
||||
jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
|
||||
|
||||
it('Should display Grafana rules when the user has the alert rule read permission', () => {
|
||||
it('Should display Grafana rules when the user has the alert rule read permission', async () => {
|
||||
const grafanaNamespace = getGrafanaNamespace();
|
||||
const namespaces: CombinedRuleNamespace[] = [grafanaNamespace];
|
||||
|
||||
@ -43,10 +43,12 @@ describe('RuleListGroupView', () => {
|
||||
|
||||
renderRuleList(namespaces);
|
||||
|
||||
expect(ui.grafanaRulesHeading.get()).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(ui.grafanaRulesHeading.get()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should display Cloud rules when the user has the external rules read permission', () => {
|
||||
it('Should display Cloud rules when the user has the external rules read permission', async () => {
|
||||
const cloudNamespace = getCloudNamespace();
|
||||
const namespaces: CombinedRuleNamespace[] = [cloudNamespace];
|
||||
|
||||
@ -56,10 +58,12 @@ describe('RuleListGroupView', () => {
|
||||
|
||||
renderRuleList(namespaces);
|
||||
|
||||
expect(ui.cloudRulesHeading.get()).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(ui.cloudRulesHeading.get()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should not display Grafana rules when the user does not have alert rule read permission', () => {
|
||||
it('Should not display Grafana rules when the user does not have alert rule read permission', async () => {
|
||||
const grafanaNamespace = getGrafanaNamespace();
|
||||
const namespaces: CombinedRuleNamespace[] = [grafanaNamespace];
|
||||
|
||||
@ -67,10 +71,12 @@ describe('RuleListGroupView', () => {
|
||||
|
||||
renderRuleList(namespaces);
|
||||
|
||||
expect(ui.grafanaRulesHeading.query()).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(ui.grafanaRulesHeading.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should not display Cloud rules when the user does not have the external rules read permission', () => {
|
||||
it('Should not display Cloud rules when the user does not have the external rules read permission', async () => {
|
||||
const cloudNamespace = getCloudNamespace();
|
||||
|
||||
const namespaces: CombinedRuleNamespace[] = [cloudNamespace];
|
||||
@ -80,7 +86,9 @@ describe('RuleListGroupView', () => {
|
||||
|
||||
renderRuleList(namespaces);
|
||||
|
||||
expect(ui.cloudRulesHeading.query()).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(ui.cloudRulesHeading.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,51 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { useDispatch } from 'app/types';
|
||||
import { CombinedRule, RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting';
|
||||
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
import { CombinedRule, RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { fetchPromAndRulerRulesAction } from '../state/actions';
|
||||
import { getDataSourceByName, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
|
||||
import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
import { isRulerNotSupportedResponse } from '../utils/rules';
|
||||
import {
|
||||
isCloudRuleIdentifier,
|
||||
isGrafanaRuleIdentifier,
|
||||
isPrometheusRuleIdentifier,
|
||||
isRulerNotSupportedResponse,
|
||||
} from '../utils/rules';
|
||||
|
||||
import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces';
|
||||
import {
|
||||
attachRulerRulesToCombinedRules,
|
||||
combineRulesNamespaces,
|
||||
useCombinedRuleNamespaces,
|
||||
} from './useCombinedRuleNamespaces';
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
|
||||
export function useCombinedRule(
|
||||
identifier: RuleIdentifier | undefined,
|
||||
ruleSourceName: string | undefined
|
||||
): AsyncRequestState<CombinedRule> {
|
||||
const requestState = useCombinedRulesLoader(ruleSourceName, identifier);
|
||||
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
|
||||
@ -79,6 +58,67 @@ export function useCombinedRulesMatching(
|
||||
};
|
||||
}
|
||||
|
||||
export function useCloudCombinedRulesMatching(
|
||||
ruleName: string,
|
||||
ruleSourceName: string,
|
||||
filter?: { namespace?: string; groupName?: string }
|
||||
): { loading: boolean; error?: unknown; rules?: CombinedRule[] } {
|
||||
const dsSettings = getDataSourceByName(ruleSourceName);
|
||||
const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName);
|
||||
|
||||
const {
|
||||
currentData: promRuleNs = [],
|
||||
isLoading: isLoadingPromRules,
|
||||
error: promRuleNsError,
|
||||
} = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery({
|
||||
ruleSourceName: ruleSourceName,
|
||||
ruleName: ruleName,
|
||||
namespace: filter?.namespace,
|
||||
groupName: filter?.groupName,
|
||||
});
|
||||
|
||||
const [fetchRulerRuleGroup] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery();
|
||||
|
||||
const { loading, error, value } = useAsync(async () => {
|
||||
if (!dsSettings) {
|
||||
throw new Error('Unable to obtain data source settings');
|
||||
}
|
||||
|
||||
if (promRuleNsError) {
|
||||
throw new Error('Unable to obtain Prometheus rules');
|
||||
}
|
||||
|
||||
const rulerGroups: RulerRuleGroupDTO[] = [];
|
||||
if (dsFeatures?.rulerConfig) {
|
||||
const rulerConfig = dsFeatures.rulerConfig;
|
||||
|
||||
const nsGroups = promRuleNs
|
||||
.map((namespace) => namespace.groups.map((group) => ({ namespace: namespace, group: group })))
|
||||
.flat();
|
||||
|
||||
// RTK query takes care of deduplication
|
||||
await Promise.allSettled(
|
||||
nsGroups.map(async (nsGroup) => {
|
||||
const rulerGroup = await fetchRulerRuleGroup({
|
||||
rulerConfig: rulerConfig,
|
||||
namespace: nsGroup.namespace.name,
|
||||
group: nsGroup.group.name,
|
||||
}).unwrap();
|
||||
rulerGroups.push(rulerGroup);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// TODO Join with ruler rules
|
||||
const namespaces = promRuleNs.map((ns) => attachRulerRulesToCombinedRules(dsSettings, ns, rulerGroups));
|
||||
const rules = namespaces.flatMap((ns) => ns.groups.flatMap((group) => group.rules));
|
||||
|
||||
return rules;
|
||||
}, [dsSettings, dsFeatures, isLoadingPromRules, promRuleNsError, promRuleNs, fetchRulerRuleGroup]);
|
||||
|
||||
return { loading: isLoadingDsFeatures || loading, error: error, rules: value };
|
||||
}
|
||||
|
||||
function useCombinedRulesLoader(
|
||||
rulesSourceName: string | undefined,
|
||||
identifier?: RuleIdentifier
|
||||
@ -120,3 +160,150 @@ function getRequestState(
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }): {
|
||||
loading: boolean;
|
||||
result?: CombinedRule;
|
||||
error?: unknown;
|
||||
} {
|
||||
const { ruleSourceName } = ruleIdentifier;
|
||||
const dsSettings = getDataSourceByName(ruleSourceName);
|
||||
|
||||
const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName);
|
||||
|
||||
const {
|
||||
currentData: promRuleNs,
|
||||
isLoading: isLoadingPromRules,
|
||||
error: promRuleNsError,
|
||||
} = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery(
|
||||
{
|
||||
// TODO Refactor parameters
|
||||
ruleSourceName: ruleIdentifier.ruleSourceName,
|
||||
namespace:
|
||||
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
|
||||
? ruleIdentifier.namespace
|
||||
: undefined,
|
||||
groupName:
|
||||
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
|
||||
? ruleIdentifier.groupName
|
||||
: undefined,
|
||||
ruleName:
|
||||
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
|
||||
? ruleIdentifier.ruleName
|
||||
: undefined,
|
||||
}
|
||||
// TODO – experiment with enabling these now that we request a single alert rule more efficiently.
|
||||
// Requires a recent version of Prometheus with support for query params on /api/v1/rules
|
||||
// {
|
||||
// refetchOnFocus: true,
|
||||
// refetchOnReconnect: true,
|
||||
// }
|
||||
);
|
||||
|
||||
const [
|
||||
fetchRulerRuleGroup,
|
||||
{ currentData: rulerRuleGroup, isLoading: isLoadingRulerGroup, error: rulerRuleGroupError },
|
||||
] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery();
|
||||
|
||||
const [fetchRulerRules, { currentData: rulerRules, isLoading: isLoadingRulerRules, error: rulerRulesError }] =
|
||||
alertRuleApi.endpoints.rulerRules.useLazyQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (!dsFeatures?.rulerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dsFeatures.rulerConfig && isCloudRuleIdentifier(ruleIdentifier)) {
|
||||
fetchRulerRuleGroup({
|
||||
rulerConfig: dsFeatures.rulerConfig,
|
||||
namespace: ruleIdentifier.namespace,
|
||||
group: ruleIdentifier.groupName,
|
||||
});
|
||||
} else if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
||||
// TODO Fetch a single group for Grafana managed rules, we're currently still fetching all rules for Grafana managed
|
||||
fetchRulerRules({ rulerConfig: dsFeatures.rulerConfig });
|
||||
}
|
||||
}, [dsFeatures, fetchRulerRuleGroup, fetchRulerRules, ruleIdentifier]);
|
||||
|
||||
const rule = useMemo(() => {
|
||||
if (!promRuleNs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
||||
const combinedNamespaces = combineRulesNamespaces('grafana', promRuleNs, rulerRules);
|
||||
|
||||
for (const namespace of combinedNamespaces) {
|
||||
for (const group of namespace.groups) {
|
||||
for (const rule of group.rules) {
|
||||
const id = ruleId.fromCombinedRule(ruleSourceName, rule);
|
||||
|
||||
if (ruleId.equal(id, ruleIdentifier)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dsSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
promRuleNs.length > 0 &&
|
||||
(isCloudRuleIdentifier(ruleIdentifier) || isPrometheusRuleIdentifier(ruleIdentifier))
|
||||
) {
|
||||
const namespaces = promRuleNs.map((ns) =>
|
||||
attachRulerRulesToCombinedRules(dsSettings, ns, rulerRuleGroup ? [rulerRuleGroup] : [])
|
||||
);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
for (const group of namespace.groups) {
|
||||
for (const rule of group.rules) {
|
||||
const id = ruleId.fromCombinedRule(ruleSourceName, rule);
|
||||
|
||||
if (ruleId.equal(id, ruleIdentifier)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, rulerRules, dsSettings]);
|
||||
|
||||
return {
|
||||
loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || isLoadingRulerRules,
|
||||
error: promRuleNsError ?? rulerRuleGroupError ?? rulerRulesError,
|
||||
result: rule,
|
||||
};
|
||||
}
|
||||
|
||||
const grafanaRulerConfig: RulerDataSourceConfig = {
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||
apiVersion: 'legacy',
|
||||
};
|
||||
|
||||
const grafanaDsFeatures = {
|
||||
rulerConfig: grafanaRulerConfig,
|
||||
};
|
||||
|
||||
export function useDataSourceFeatures(dataSourceName: string) {
|
||||
const isGrafanaDs = isGrafanaRulesSource(dataSourceName);
|
||||
|
||||
const { currentData: dsFeatures, isLoading: isLoadingDsFeatures } =
|
||||
featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery(
|
||||
{
|
||||
rulesSourceName: dataSourceName,
|
||||
},
|
||||
{ skip: isGrafanaDs }
|
||||
);
|
||||
|
||||
if (isGrafanaDs) {
|
||||
return { isLoadingDsFeatures: false, dsFeatures: grafanaDsFeatures };
|
||||
}
|
||||
|
||||
return { isLoadingDsFeatures, dsFeatures };
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export function useCombinedRuleNamespaces(
|
||||
}
|
||||
const namespaces: Record<string, CombinedRuleNamespace> = {};
|
||||
|
||||
// first get all the ruler rules in
|
||||
// first get all the ruler rules from the data source
|
||||
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
|
||||
const namespace: CombinedRuleNamespace = {
|
||||
rulesSource,
|
||||
@ -115,6 +115,72 @@ export function useCombinedRuleNamespaces(
|
||||
}, [promRulesResponses, rulerRulesResponses, rulesSources, grafanaPromRuleNamespaces]);
|
||||
}
|
||||
|
||||
export function combineRulesNamespaces(
|
||||
rulesSource: RulesSource,
|
||||
promNamespaces: RuleNamespace[],
|
||||
rulerRules?: RulerRulesConfigDTO
|
||||
): CombinedRuleNamespace[] {
|
||||
const namespaces: Record<string, CombinedRuleNamespace> = {};
|
||||
|
||||
// first get all the ruler rules from the data source
|
||||
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
|
||||
const namespace: CombinedRuleNamespace = {
|
||||
rulesSource,
|
||||
name: namespaceName,
|
||||
groups: [],
|
||||
};
|
||||
namespaces[namespaceName] = namespace;
|
||||
addRulerGroupsToCombinedNamespace(namespace, groups);
|
||||
});
|
||||
|
||||
// then correlate with prometheus rules
|
||||
promNamespaces?.forEach(({ name: namespaceName, groups }) => {
|
||||
const ns = (namespaces[namespaceName] = namespaces[namespaceName] || {
|
||||
rulesSource,
|
||||
name: namespaceName,
|
||||
groups: [],
|
||||
});
|
||||
|
||||
addPromGroupsToCombinedNamespace(ns, groups);
|
||||
});
|
||||
|
||||
return Object.values(namespaces);
|
||||
}
|
||||
|
||||
export function attachRulerRulesToCombinedRules(
|
||||
rulesSource: RulesSource,
|
||||
promNamespace: RuleNamespace,
|
||||
rulerGroups: RulerRuleGroupDTO[]
|
||||
): CombinedRuleNamespace {
|
||||
const ns: CombinedRuleNamespace = {
|
||||
rulesSource: rulesSource,
|
||||
name: promNamespace.name,
|
||||
groups: [],
|
||||
};
|
||||
|
||||
// The order is important. Adding Ruler rules overrides Prometheus rules.
|
||||
addRulerGroupsToCombinedNamespace(ns, rulerGroups);
|
||||
addPromGroupsToCombinedNamespace(ns, promNamespace.groups);
|
||||
|
||||
// Remove ruler rules which does not have Prom rule counterpart
|
||||
// This function should only attach Ruler rules to existing Prom rules
|
||||
ns.groups.forEach((group) => {
|
||||
group.rules = group.rules.filter((rule) => rule.promRule);
|
||||
});
|
||||
|
||||
return ns;
|
||||
}
|
||||
|
||||
export function addCombinedPromAndRulerGroups(
|
||||
ns: CombinedRuleNamespace,
|
||||
promGroups: RuleGroup[],
|
||||
rulerGroups: RulerRuleGroupDTO[]
|
||||
): CombinedRuleNamespace {
|
||||
addRulerGroupsToCombinedNamespace(ns, rulerGroups);
|
||||
addPromGroupsToCombinedNamespace(ns, promGroups);
|
||||
return ns;
|
||||
}
|
||||
|
||||
// merge all groups in case of grafana managed, essentially treating namespaces (folders) as groups
|
||||
export function flattenGrafanaManagedRules(namespaces: CombinedRuleNamespace[]) {
|
||||
return namespaces.map((namespace) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
@ -30,7 +30,7 @@ describe('useIsRuleEditable', () => {
|
||||
beforeEach(enableRBAC);
|
||||
describe('Grafana rules', () => {
|
||||
// When RBAC is enabled we require appropriate alerting permissions in the folder scope
|
||||
it('Should allow editing when the user has the alert rule update permission in the folder', () => {
|
||||
it('Should allow editing when the user has the alert rule update permission in the folder', async () => {
|
||||
mockUseFolder({
|
||||
accessControl: {
|
||||
[AccessControlAction.AlertingRuleUpdate]: true,
|
||||
@ -41,11 +41,11 @@ describe('useIsRuleEditable', () => {
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isEditable).toBe(true);
|
||||
});
|
||||
|
||||
it('Should allow deleting when the user has the alert rule delete permission', () => {
|
||||
it('Should allow deleting when the user has the alert rule delete permission', async () => {
|
||||
mockUseFolder({
|
||||
accessControl: {
|
||||
[AccessControlAction.AlertingRuleDelete]: true,
|
||||
@ -56,33 +56,33 @@ describe('useIsRuleEditable', () => {
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isRemovable).toBe(true);
|
||||
});
|
||||
|
||||
it('Should forbid editing when the user has no alert rule update permission', () => {
|
||||
it('Should forbid editing when the user has no alert rule update permission', async () => {
|
||||
mockUseFolder({ accessControl: {} });
|
||||
|
||||
const wrapper = getProviderWrapper();
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isEditable).toBe(false);
|
||||
});
|
||||
|
||||
it('Should forbid deleting when the user has no alert rule delete permission', () => {
|
||||
it('Should forbid deleting when the user has no alert rule delete permission', async () => {
|
||||
mockUseFolder({ accessControl: {} });
|
||||
|
||||
const wrapper = getProviderWrapper();
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isRemovable).toBe(false);
|
||||
});
|
||||
|
||||
it('Should allow editing and deleting when the user has alert rule permissions but does not have folder canSave permission', () => {
|
||||
it('Should allow editing and deleting when the user has alert rule permissions but does not have folder canSave permission', async () => {
|
||||
mockUseFolder({
|
||||
canSave: false,
|
||||
accessControl: {
|
||||
@ -95,7 +95,7 @@ describe('useIsRuleEditable', () => {
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isEditable).toBe(true);
|
||||
expect(result.current.isRemovable).toBe(true);
|
||||
});
|
||||
@ -103,27 +103,28 @@ describe('useIsRuleEditable', () => {
|
||||
|
||||
describe('Cloud rules', () => {
|
||||
beforeEach(() => {
|
||||
mocks.useFolder.mockReturnValue({ loading: false });
|
||||
contextSrv.isEditor = true;
|
||||
});
|
||||
|
||||
it('Should allow editing and deleting when the user has alert rule external write permission', () => {
|
||||
it('Should allow editing and deleting when the user has alert rule external write permission', async () => {
|
||||
mockPermissions([AccessControlAction.AlertingRuleExternalWrite]);
|
||||
const wrapper = getProviderWrapper();
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isEditable).toBe(true);
|
||||
expect(result.current.isRemovable).toBe(true);
|
||||
});
|
||||
|
||||
it('Should forbid editing and deleting when the user has no alert rule external write permission', () => {
|
||||
it('Should forbid editing and deleting when the user has no alert rule external write permission', async () => {
|
||||
mockPermissions([]);
|
||||
const wrapper = getProviderWrapper();
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isEditable).toBe(false);
|
||||
expect(result.current.isRemovable).toBe(false);
|
||||
});
|
||||
@ -133,26 +134,26 @@ describe('useIsRuleEditable', () => {
|
||||
describe('RBAC disabled', () => {
|
||||
beforeEach(disableRBAC);
|
||||
describe('Grafana rules', () => {
|
||||
it('Should allow editing and deleting when the user has folder canSave permission', () => {
|
||||
it('Should allow editing and deleting when the user has folder canSave permission', async () => {
|
||||
mockUseFolder({ canSave: true });
|
||||
|
||||
const wrapper = getProviderWrapper();
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isEditable).toBe(true);
|
||||
expect(result.current.isRemovable).toBe(true);
|
||||
});
|
||||
|
||||
it('Should forbid editing and deleting when the user has no folder canSave permission', () => {
|
||||
it('Should forbid editing and deleting when the user has no folder canSave permission', async () => {
|
||||
mockUseFolder({ canSave: false });
|
||||
|
||||
const wrapper = getProviderWrapper();
|
||||
|
||||
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.isEditable).toBe(false);
|
||||
expect(result.current.isRemovable).toBe(false);
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { getRulesPermissions } from '../utils/access-control';
|
||||
import { isGrafanaRulerRule } from '../utils/rules';
|
||||
|
||||
@ -15,6 +16,10 @@ interface ResultBag {
|
||||
|
||||
export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag {
|
||||
const dataSources = useUnifiedAlertingSelector((state) => state.dataSources);
|
||||
const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
|
||||
rulesSourceName,
|
||||
});
|
||||
|
||||
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
|
||||
|
||||
const rulePermission = getRulesPermissions(rulesSourceName);
|
||||
@ -50,18 +55,19 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
|
||||
return {
|
||||
isEditable: canEditGrafanaRules,
|
||||
isRemovable: canRemoveGrafanaRules,
|
||||
loading,
|
||||
loading: loading || isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
// prom rules are only editable by users with Editor role and only if rules source supports editing
|
||||
const isRulerAvailable = Boolean(dataSources[rulesSourceName]?.result?.rulerConfig);
|
||||
const isRulerAvailable =
|
||||
Boolean(dataSources[rulesSourceName]?.result?.rulerConfig) || Boolean(dsFeatures?.rulerConfig);
|
||||
const canEditCloudRules = contextSrv.hasAccess(rulePermission.update, contextSrv.isEditor);
|
||||
const canRemoveCloudRules = contextSrv.hasAccess(rulePermission.delete, contextSrv.isEditor);
|
||||
|
||||
return {
|
||||
isEditable: canEditCloudRules && isRulerAvailable,
|
||||
isRemovable: canRemoveCloudRules && isRulerAvailable,
|
||||
loading: dataSources[rulesSourceName]?.loading,
|
||||
loading: isLoading || dataSources[rulesSourceName]?.loading,
|
||||
};
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { setupServer, SetupServer } from 'msw/node';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { PromRulesResponse, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { backendSrv } from '../../../core/services/backend_srv';
|
||||
import {
|
||||
@ -124,6 +125,30 @@ export function mockApi(server: SetupServer) {
|
||||
};
|
||||
}
|
||||
|
||||
export function mockAlertRuleApi(server: SetupServer) {
|
||||
return {
|
||||
prometheusRuleNamespaces: (dsName: string, response: PromRulesResponse) => {
|
||||
server.use(
|
||||
rest.get(`api/prometheus/${dsName}/api/v1/rules`, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json<PromRulesResponse>(response))
|
||||
)
|
||||
);
|
||||
},
|
||||
rulerRules: (dsName: string, response: RulerRulesConfigDTO) => {
|
||||
server.use(
|
||||
rest.get(`/api/ruler/${dsName}/api/v1/rules`, (req, res, ctx) => res(ctx.status(200), ctx.json(response)))
|
||||
);
|
||||
},
|
||||
rulerRuleGroup: (dsName: string, namespace: string, group: string, response: RulerRuleGroupDTO) => {
|
||||
server.use(
|
||||
rest.get(`/api/ruler/${dsName}/api/v1/rules/${namespace}/${group}`, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response))
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it
|
||||
export function setupMswServer() {
|
||||
const server = setupServer();
|
||||
|
@ -155,6 +155,20 @@ export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): Ru
|
||||
...partial,
|
||||
});
|
||||
|
||||
export const promRuleFromRulerRule = (
|
||||
rulerRule: RulerAlertingRuleDTO,
|
||||
override?: Partial<AlertingRule>
|
||||
): AlertingRule => {
|
||||
return mockPromAlertingRule({
|
||||
name: rulerRule.alert,
|
||||
query: rulerRule.expr,
|
||||
labels: rulerRule.labels,
|
||||
annotations: rulerRule.annotations,
|
||||
type: PromRuleType.Alerting,
|
||||
...override,
|
||||
});
|
||||
};
|
||||
|
||||
export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): AlertingRule => {
|
||||
return {
|
||||
type: PromRuleType.Alerting,
|
||||
@ -176,13 +190,12 @@ export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): Alert
|
||||
};
|
||||
};
|
||||
|
||||
export const mockGrafanaRulerRule = (partial: Partial<RulerGrafanaRuleDTO> = {}): RulerGrafanaRuleDTO => {
|
||||
export const mockGrafanaRulerRule = (partial: Partial<GrafanaRuleDefinition> = {}): RulerGrafanaRuleDTO => {
|
||||
return {
|
||||
for: '',
|
||||
annotations: {},
|
||||
labels: {},
|
||||
grafana_alert: {
|
||||
...partial,
|
||||
uid: '',
|
||||
title: 'my rule',
|
||||
namespace_uid: '',
|
||||
@ -191,6 +204,7 @@ export const mockGrafanaRulerRule = (partial: Partial<RulerGrafanaRuleDTO> = {})
|
||||
no_data_state: GrafanaAlertStateDecision.NoData,
|
||||
exec_err_state: GrafanaAlertStateDecision.Error,
|
||||
data: [],
|
||||
...partial,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -624,14 +638,14 @@ export function mockCombinedRuleNamespace(namespace: Partial<CombinedRuleNamespa
|
||||
};
|
||||
}
|
||||
|
||||
export function getGrafanaRule(override?: Partial<CombinedRule>) {
|
||||
export function getGrafanaRule(override?: Partial<CombinedRule>, rulerOverride?: Partial<GrafanaRuleDefinition>) {
|
||||
return mockCombinedRule({
|
||||
namespace: {
|
||||
groups: [],
|
||||
name: 'Grafana',
|
||||
rulesSource: 'grafana',
|
||||
},
|
||||
rulerRule: mockGrafanaRulerRule(),
|
||||
rulerRule: mockGrafanaRulerRule(rulerOverride),
|
||||
...override,
|
||||
});
|
||||
}
|
||||
|
14
public/app/features/alerting/unified/mocks/plugins.ts
Normal file
14
public/app/features/alerting/unified/mocks/plugins.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { rest } from 'msw';
|
||||
import { SetupServer } from 'msw/lib/node';
|
||||
|
||||
import { PluginMeta } from '@grafana/data';
|
||||
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
export function mockPluginSettings(server: SetupServer, plugin: SupportedPlugin, response?: PluginMeta) {
|
||||
server.use(
|
||||
rest.get(`/api/plugins/${plugin}/settings`, (_req, res, ctx) => {
|
||||
return response ? res(ctx.status(200), ctx.json(response)) : res(ctx.status(404));
|
||||
})
|
||||
);
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import {
|
||||
GrafanaAlertStateDecision,
|
||||
GrafanaRuleDefinition,
|
||||
@ -61,16 +62,17 @@ describe('hashRulerRule', () => {
|
||||
});
|
||||
|
||||
it('should correctly encode and decode unix-style path separators', () => {
|
||||
const identifier = {
|
||||
const identifier: RuleIdentifier = {
|
||||
ruleSourceName: 'my-datasource',
|
||||
namespace: 'folder1/folder2',
|
||||
groupName: 'group1/group2',
|
||||
ruleName: 'CPU-firing',
|
||||
ruleHash: 'abc123',
|
||||
};
|
||||
|
||||
const encodedIdentifier = encodeURIComponent(stringifyIdentifier(identifier));
|
||||
|
||||
expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Ffolder2%24group1%1Fgroup2%24abc123');
|
||||
expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Ffolder2%24group1%1Fgroup2%24CPU-firing%24abc123');
|
||||
expect(encodedIdentifier).not.toContain('%2F');
|
||||
expect(parse(encodedIdentifier, true)).toStrictEqual(identifier);
|
||||
});
|
||||
@ -80,10 +82,13 @@ describe('hashRulerRule', () => {
|
||||
ruleSourceName: 'my-datasource',
|
||||
namespace: 'folder1/folder2',
|
||||
groupName: 'group1/group2',
|
||||
ruleName: 'CPU-firing/burning',
|
||||
ruleHash: 'abc123',
|
||||
};
|
||||
|
||||
expect(parse('pri%24my-datasource%24folder1%2Ffolder2%24group1%2Fgroup2%24abc123', true)).toStrictEqual(identifier);
|
||||
expect(
|
||||
parse('pri%24my-datasource%24folder1%2Ffolder2%24group1%2Fgroup2%24CPU-firing%2Fburning%24abc123', true)
|
||||
).toStrictEqual(identifier);
|
||||
});
|
||||
|
||||
it('should correctly encode and decode windows-style path separators', () => {
|
||||
@ -91,12 +96,13 @@ describe('hashRulerRule', () => {
|
||||
ruleSourceName: 'my-datasource',
|
||||
namespace: 'folder1\\folder2',
|
||||
groupName: 'group1\\group2',
|
||||
ruleName: 'CPU-firing',
|
||||
ruleHash: 'abc123',
|
||||
};
|
||||
|
||||
const encodedIdentifier = encodeURIComponent(stringifyIdentifier(identifier));
|
||||
|
||||
expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Efolder2%24group1%1Egroup2%24abc123');
|
||||
expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Efolder2%24group1%1Egroup2%24CPU-firing%24abc123');
|
||||
expect(parse(encodedIdentifier, true)).toStrictEqual(identifier);
|
||||
});
|
||||
|
||||
|
@ -26,6 +26,7 @@ export function fromRulerRule(
|
||||
ruleSourceName,
|
||||
namespace,
|
||||
groupName,
|
||||
ruleName: isAlertingRulerRule(rule) ? rule.alert : rule.record,
|
||||
rulerRuleHash: hashRulerRule(rule),
|
||||
};
|
||||
}
|
||||
@ -35,6 +36,7 @@ export function fromRule(ruleSourceName: string, namespace: string, groupName: s
|
||||
ruleSourceName,
|
||||
namespace,
|
||||
groupName,
|
||||
ruleName: rule.name,
|
||||
ruleHash: hashRule(rule),
|
||||
};
|
||||
}
|
||||
@ -67,6 +69,7 @@ export function equal(a: RuleIdentifier, b: RuleIdentifier) {
|
||||
return (
|
||||
a.groupName === b.groupName &&
|
||||
a.namespace === b.namespace &&
|
||||
a.ruleName === b.ruleName &&
|
||||
a.rulerRuleHash === b.rulerRuleHash &&
|
||||
a.ruleSourceName === b.ruleSourceName
|
||||
);
|
||||
@ -76,6 +79,7 @@ export function equal(a: RuleIdentifier, b: RuleIdentifier) {
|
||||
return (
|
||||
a.groupName === b.groupName &&
|
||||
a.namespace === b.namespace &&
|
||||
a.ruleName === b.ruleName &&
|
||||
a.ruleHash === b.ruleHash &&
|
||||
a.ruleSourceName === b.ruleSourceName
|
||||
);
|
||||
@ -118,15 +122,17 @@ export function parse(value: string, decodeFromUri = false): RuleIdentifier {
|
||||
return { uid: value, ruleSourceName: 'grafana' };
|
||||
}
|
||||
|
||||
if (parts.length === 5) {
|
||||
const [prefix, ruleSourceName, namespace, groupName, hash] = parts.map(unescapeDollars).map(unescapePathSeparators);
|
||||
if (parts.length === 6) {
|
||||
const [prefix, ruleSourceName, namespace, groupName, ruleName, hash] = parts
|
||||
.map(unescapeDollars)
|
||||
.map(unescapePathSeparators);
|
||||
|
||||
if (prefix === cloudRuleIdentifierPrefix) {
|
||||
return { ruleSourceName, namespace, groupName, rulerRuleHash: hash };
|
||||
return { ruleSourceName, namespace, groupName, ruleName, rulerRuleHash: hash };
|
||||
}
|
||||
|
||||
if (prefix === prometheusRuleIdentifierPrefix) {
|
||||
return { ruleSourceName, namespace, groupName, ruleHash: hash };
|
||||
return { ruleSourceName, namespace, groupName, ruleName, ruleHash: hash };
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,6 +162,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string {
|
||||
identifier.ruleSourceName,
|
||||
identifier.namespace,
|
||||
identifier.groupName,
|
||||
identifier.ruleName,
|
||||
identifier.rulerRuleHash,
|
||||
]
|
||||
.map(String)
|
||||
@ -169,6 +176,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string {
|
||||
identifier.ruleSourceName,
|
||||
identifier.namespace,
|
||||
identifier.groupName,
|
||||
identifier.ruleName,
|
||||
identifier.ruleHash,
|
||||
]
|
||||
.map(String)
|
||||
|
@ -113,7 +113,7 @@ interface PromRuleDTOBase {
|
||||
}
|
||||
|
||||
export interface PromAlertingRuleDTO extends PromRuleDTOBase {
|
||||
alerts: Array<{
|
||||
alerts?: Array<{
|
||||
labels: Labels;
|
||||
annotations: Annotations;
|
||||
state: Exclude<PromAlertingRuleState | GrafanaAlertStateWithReason, PromAlertingRuleState.Inactive>;
|
||||
|
@ -18,7 +18,7 @@ export type Alert = {
|
||||
activeAt: string;
|
||||
annotations: { [key: string]: string };
|
||||
labels: { [key: string]: string };
|
||||
state: PromAlertingRuleState | GrafanaAlertStateWithReason;
|
||||
state: Exclude<PromAlertingRuleState | GrafanaAlertStateWithReason, PromAlertingRuleState.Inactive>;
|
||||
value: string;
|
||||
};
|
||||
|
||||
@ -154,6 +154,7 @@ export interface CloudRuleIdentifier {
|
||||
ruleSourceName: string;
|
||||
namespace: string;
|
||||
groupName: string;
|
||||
ruleName: string;
|
||||
rulerRuleHash: string;
|
||||
}
|
||||
export interface GrafanaRuleIdentifier {
|
||||
@ -166,6 +167,7 @@ export interface PrometheusRuleIdentifier {
|
||||
ruleSourceName: string;
|
||||
namespace: string;
|
||||
groupName: string;
|
||||
ruleName: string;
|
||||
ruleHash: string;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user