mirror of
https://github.com/grafana/grafana.git
synced 2025-01-15 19:22:34 -06:00
Alerting: Detail v2 part 2 (#80577)
This commit is contained in:
parent
1a794e8822
commit
d84d0c8889
@ -1998,9 +1998,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/alerting/unified/components/rules/AlertInstanceStateFilter.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/CloneRule.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/CloudRules.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
|
@ -101,6 +101,7 @@ export const availableIconsIndex = {
|
||||
'file-blank': true,
|
||||
'file-copy-alt': true,
|
||||
'file-download': true,
|
||||
'file-edit-alt': true,
|
||||
'file-landscape-alt': true,
|
||||
filter: true,
|
||||
flip: true,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
@ -7,6 +7,7 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynami
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { AlertRuleProvider } from './components/rule-viewer/v2/RuleContext';
|
||||
import { useCombinedRule } from './hooks/useCombinedRule';
|
||||
import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id';
|
||||
|
||||
@ -33,7 +34,10 @@ const RuleViewerV1Wrapper = (props: RuleViewerProps) => <DetailViewV1 {...props}
|
||||
|
||||
const RuleViewerV2Wrapper = (props: RuleViewerProps) => {
|
||||
const id = getRuleIdFromPathname(props.match.params);
|
||||
const identifier = useMemo(() => {
|
||||
|
||||
// we convert the stringified ID to a rule identifier object which contains additional
|
||||
// type and source information
|
||||
const identifier = React.useMemo(() => {
|
||||
if (!id) {
|
||||
throw new Error('Rule ID is required');
|
||||
}
|
||||
@ -41,6 +45,7 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => {
|
||||
return parseRuleId(id, true);
|
||||
}, [id]);
|
||||
|
||||
// we then fetch the rule from the correct API endpoint(s)
|
||||
const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
|
||||
|
||||
// TODO improve error handling here
|
||||
@ -61,7 +66,11 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => {
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
return <DetailViewV2 rule={rule} identifier={identifier} />;
|
||||
return (
|
||||
<AlertRuleProvider identifier={identifier} rule={rule}>
|
||||
<DetailViewV2 />
|
||||
</AlertRuleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -18,9 +18,10 @@ interface Props {
|
||||
// TODO allow customization with color prop
|
||||
const Label = ({ label, value, icon, color, size = 'md' }: Props) => {
|
||||
const styles = useStyles2(getStyles, color, size);
|
||||
const ariaLabel = `${label}: ${value}`;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} role="listitem">
|
||||
<div className={styles.wrapper} role="listitem" aria-label={ariaLabel}>
|
||||
<Stack direction="row" gap={0} alignItems="stretch">
|
||||
<div className={styles.label}>
|
||||
<Stack direction="row" gap={0.5} alignItems="center">
|
||||
|
@ -50,6 +50,8 @@ const Details = ({ rule }: DetailsProps) => {
|
||||
? rule.annotations ?? []
|
||||
: undefined;
|
||||
|
||||
const hasEvaluationDuration = Number.isFinite(evaluationDuration);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={3}>
|
||||
<div className={styles.metadataWrapper}>
|
||||
@ -74,7 +76,7 @@ const Details = ({ rule }: DetailsProps) => {
|
||||
|
||||
{/* evaluation duration and pending period */}
|
||||
<MetaText direction="column">
|
||||
{evaluationDuration && (
|
||||
{hasEvaluationDuration && (
|
||||
<>
|
||||
Last evaluation
|
||||
{evaluationTimestamp && evaluationDuration && (
|
||||
|
@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { Dropdown, LinkButton, Menu } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities';
|
||||
import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../../utils/misc';
|
||||
import * as ruleId from '../../../utils/rule-id';
|
||||
import { createUrl } from '../../../utils/url';
|
||||
import MoreButton from '../../MoreButton';
|
||||
import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton';
|
||||
|
||||
import { useAlertRule } from './RuleContext';
|
||||
|
||||
interface Props {
|
||||
handleDelete: (rule: CombinedRule) => void;
|
||||
handleDuplicateRule: (identifier: RuleIdentifier) => void;
|
||||
}
|
||||
|
||||
export const useAlertRulePageActions = ({ handleDelete, handleDuplicateRule }: Props) => {
|
||||
const { rule, identifier } = useAlertRule();
|
||||
|
||||
// check all abilities and permissions
|
||||
const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
|
||||
const canEdit = editSupported && editAllowed;
|
||||
|
||||
const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
|
||||
const canDelete = deleteSupported && deleteAllowed;
|
||||
|
||||
const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
|
||||
const canDuplicate = duplicateSupported && duplicateAllowed;
|
||||
|
||||
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence);
|
||||
const canSilence = silenceSupported && silenceAllowed;
|
||||
|
||||
const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport);
|
||||
const canExport = exportSupported && exportAllowed;
|
||||
|
||||
/**
|
||||
* Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana.
|
||||
* We should show it in development mode
|
||||
*/
|
||||
const shouldShowDeclareIncidentButton = !isOpenSourceEdition() || isLocalDevEnv();
|
||||
const shareUrl = createShareLink(rule.namespace.rulesSource, rule);
|
||||
|
||||
return [
|
||||
canEdit && <EditButton key="edit-action" identifier={identifier} />,
|
||||
<Dropdown
|
||||
key="more-actions"
|
||||
overlay={
|
||||
<Menu>
|
||||
{canSilence && (
|
||||
<Menu.Item
|
||||
label="Silence"
|
||||
icon="bell-slash"
|
||||
url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />}
|
||||
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />}
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} />
|
||||
{canExport && (
|
||||
<Menu.Item
|
||||
label="Export"
|
||||
icon="download-alt"
|
||||
childItems={[<ExportMenuItem key="export-with-modifications" identifier={identifier} />]}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => handleDelete(rule)} />
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<MoreButton size="md" />
|
||||
</Dropdown>,
|
||||
];
|
||||
};
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard?.writeText(text).then(() => {
|
||||
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
|
||||
});
|
||||
}
|
||||
|
||||
type PropsWithIdentifier = { identifier: RuleIdentifier };
|
||||
|
||||
const ExportMenuItem = ({ identifier }: PropsWithIdentifier) => {
|
||||
const returnTo = location.pathname + location.search;
|
||||
const url = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
|
||||
returnTo,
|
||||
});
|
||||
|
||||
return <Menu.Item key="with-modifications" label="With modifications" icon="file-edit-alt" url={url} />;
|
||||
};
|
||||
|
||||
const EditButton = ({ identifier }: PropsWithIdentifier) => {
|
||||
const returnTo = location.pathname + location.search;
|
||||
const ruleIdentifier = ruleId.stringifyIdentifier(identifier);
|
||||
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo });
|
||||
|
||||
return (
|
||||
<LinkButton variant="secondary" icon="pen" href={editURL}>
|
||||
Edit
|
||||
</LinkButton>
|
||||
);
|
||||
};
|
@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
interface Context {
|
||||
rule: CombinedRule;
|
||||
identifier: RuleIdentifier;
|
||||
}
|
||||
|
||||
const AlertRuleContext = React.createContext<Context | undefined>(undefined);
|
||||
|
||||
type Props = Context & React.PropsWithChildren & {};
|
||||
|
||||
const AlertRuleProvider = ({ children, rule, identifier }: Props) => {
|
||||
const value: Context = {
|
||||
rule,
|
||||
identifier,
|
||||
};
|
||||
|
||||
return <AlertRuleContext.Provider value={value}>{children}</AlertRuleContext.Provider>;
|
||||
};
|
||||
|
||||
const useAlertRule = () => {
|
||||
const context = React.useContext(AlertRuleContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAlertRule must be used within a AlertRuleContext');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export { AlertRuleProvider, useAlertRule };
|
@ -0,0 +1,169 @@
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { byText, byRole } from 'testing-library-selector';
|
||||
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../../mocks';
|
||||
import { Annotation } from '../../../utils/constants';
|
||||
import * as ruleId from '../../../utils/rule-id';
|
||||
|
||||
import { AlertRuleProvider } from './RuleContext';
|
||||
import RuleViewer from './RuleViewer.v2';
|
||||
import { createMockGrafanaServer } from './__mocks__/server';
|
||||
|
||||
// metadata and interactive elements
|
||||
const ELEMENTS = {
|
||||
loading: byText(/Loading rule/i),
|
||||
metadata: {
|
||||
summary: (text: string) => byText(text),
|
||||
runbook: (url: string) => byRole('link', { name: url }),
|
||||
dashboardAndPanel: byRole('link', { name: 'View panel' }),
|
||||
evaluationInterval: (interval: string) => byText(`Every ${interval}`),
|
||||
label: ([key, value]: [string, string]) => byRole('listitem', { name: `${key}: ${value}` }),
|
||||
},
|
||||
actions: {
|
||||
edit: byRole('link', { name: 'Edit' }),
|
||||
more: {
|
||||
button: byRole('button', { name: /More/i }),
|
||||
actions: {
|
||||
silence: byRole('link', { name: /Silence/i }),
|
||||
declareIncident: byRole('menuitem', { name: /Declare incident/i }),
|
||||
duplicate: byRole('menuitem', { name: /Duplicate/i }),
|
||||
copyLink: byRole('menuitem', { name: /Copy link/i }),
|
||||
export: byRole('menuitem', { name: /Export/i }),
|
||||
delete: byRole('menuitem', { name: /Delete/i }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('RuleViewer', () => {
|
||||
describe('Grafana managed alert rule', () => {
|
||||
const server = createMockGrafanaServer();
|
||||
|
||||
const mockRule = getGrafanaRule(
|
||||
{
|
||||
name: 'Test alert',
|
||||
annotations: {
|
||||
[Annotation.dashboardUID]: 'dashboard-1',
|
||||
[Annotation.panelID]: 'panel-1',
|
||||
[Annotation.summary]: 'This is the summary for the rule',
|
||||
[Annotation.runbookURL]: 'https://runbook.site/',
|
||||
},
|
||||
labels: {
|
||||
team: 'operations',
|
||||
severity: 'low',
|
||||
},
|
||||
group: {
|
||||
name: 'my-group',
|
||||
interval: '15m',
|
||||
rules: [],
|
||||
totals: { alerting: 1 },
|
||||
},
|
||||
},
|
||||
{ uid: 'test1' }
|
||||
);
|
||||
const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule);
|
||||
|
||||
beforeAll(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
AccessControlAction.AlertingRuleRead,
|
||||
AccessControlAction.AlertingRuleUpdate,
|
||||
AccessControlAction.AlertingRuleDelete,
|
||||
AccessControlAction.AlertingInstanceCreate,
|
||||
]);
|
||||
setBackendSrv(backendSrv);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should render a Grafana managed alert rule', async () => {
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier);
|
||||
|
||||
// assert on basic info to be visible
|
||||
expect(screen.getByText('Test alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Firing')).toBeInTheDocument();
|
||||
|
||||
// alert rule metadata
|
||||
const ruleSummary = mockRule.annotations[Annotation.summary];
|
||||
const runBookURL = mockRule.annotations[Annotation.runbookURL];
|
||||
const groupInterval = mockRule.group.interval;
|
||||
const labels = mockRule.labels;
|
||||
|
||||
expect(ELEMENTS.metadata.summary(ruleSummary).get()).toBeInTheDocument();
|
||||
expect(ELEMENTS.metadata.dashboardAndPanel.get()).toBeInTheDocument();
|
||||
expect(ELEMENTS.metadata.runbook(runBookURL).get()).toBeInTheDocument();
|
||||
expect(ELEMENTS.metadata.evaluationInterval(groupInterval!).get()).toBeInTheDocument();
|
||||
|
||||
for (const label in labels) {
|
||||
expect(ELEMENTS.metadata.label([label, labels[label]]).get()).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// actions
|
||||
await waitFor(() => {
|
||||
expect(ELEMENTS.actions.edit.get()).toBeInTheDocument();
|
||||
expect(ELEMENTS.actions.more.button.get()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check the "more actions" button
|
||||
await userEvent.click(ELEMENTS.actions.more.button.get());
|
||||
const menuItems = Object.values(ELEMENTS.actions.more.actions);
|
||||
for (const menuItem of menuItems) {
|
||||
expect(menuItem.get()).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('Data source managed alert rule', () => {
|
||||
const mockRule = getCloudRule({ name: 'cloud test alert' });
|
||||
const mockRuleIdentifier = ruleId.fromCombinedRule('mimir-1', mockRule);
|
||||
|
||||
beforeAll(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingRuleExternalRead,
|
||||
AccessControlAction.AlertingRuleExternalWrite,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render a data source managed alert rule', () => {
|
||||
renderRuleViewer(mockRule, mockRuleIdentifier);
|
||||
|
||||
// assert on basic info to be vissible
|
||||
expect(screen.getByText('Test alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Firing')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(mockRule.annotations[Annotation.summary])).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'View panel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: mockRule.annotations[Annotation.runbookURL] })).toBeInTheDocument();
|
||||
expect(screen.getByText(`Every ${mockRule.group.interval}`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier) => {
|
||||
render(
|
||||
<AlertRuleProvider identifier={identifier} rule={rule}>
|
||||
<RuleViewer />
|
||||
</AlertRuleProvider>,
|
||||
{ wrapper: TestProvider }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument());
|
||||
};
|
@ -1,47 +1,33 @@
|
||||
import { isEmpty, truncate } from 'lodash';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { AppEvents, NavModelItem, UrlQueryValue } from '@grafana/data';
|
||||
import { Alert, Button, Dropdown, LinkButton, Menu, Stack, TabContent, Text, TextLink } from '@grafana/ui';
|
||||
import { NavModelItem, UrlQueryValue } from '@grafana/data';
|
||||
import { Alert, Button, LinkButton, Stack, TabContent, Text, TextLink } from '@grafana/ui';
|
||||
import { PageInfoItem } from 'app/core/components/Page/types';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { defaultPageNav } from '../../../RuleViewer';
|
||||
import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities';
|
||||
import { Annotation } from '../../../utils/constants';
|
||||
import {
|
||||
createShareLink,
|
||||
isLocalDevEnv,
|
||||
isOpenSourceEdition,
|
||||
makeDashboardLink,
|
||||
makePanelLink,
|
||||
makeRuleBasedSilenceLink,
|
||||
} from '../../../utils/misc';
|
||||
import * as ruleId from '../../../utils/rule-id';
|
||||
import { makeDashboardLink, makePanelLink } from '../../../utils/misc';
|
||||
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules';
|
||||
import { createUrl } from '../../../utils/url';
|
||||
import { AlertLabels } from '../../AlertLabels';
|
||||
import { AlertStateDot } from '../../AlertStateDot';
|
||||
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
||||
import MoreButton from '../../MoreButton';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||
import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton';
|
||||
import { decodeGrafanaNamespace } from '../../expressions/util';
|
||||
import { RedirectToCloneRule } from '../../rules/CloneRule';
|
||||
import { Details } from '../tabs/Details';
|
||||
import { History } from '../tabs/History';
|
||||
import { InstancesList } from '../tabs/Instances';
|
||||
import { QueryResults } from '../tabs/Query';
|
||||
import { Routing } from '../tabs/Routing';
|
||||
|
||||
import { useAlertRulePageActions } from './Actions';
|
||||
import { useDeleteModal } from './DeleteModal';
|
||||
|
||||
type RuleViewerProps = {
|
||||
rule: CombinedRule;
|
||||
identifier: RuleIdentifier;
|
||||
};
|
||||
import { useAlertRule } from './RuleContext';
|
||||
|
||||
enum ActiveTab {
|
||||
Query = 'query',
|
||||
@ -51,24 +37,20 @@ enum ActiveTab {
|
||||
Details = 'details',
|
||||
}
|
||||
|
||||
const RuleViewer = ({ rule, identifier }: RuleViewerProps) => {
|
||||
const RuleViewer = () => {
|
||||
const { rule } = useAlertRule();
|
||||
const { pageNav, activeTab } = usePageNav(rule);
|
||||
|
||||
// this will be used to track if we are in the process of cloning a rule
|
||||
// we want to be able to show a modal if the rule has been provisioned explain the limitations
|
||||
// of duplicating provisioned alert rules
|
||||
const [duplicateRuleIdentifier, setDuplicateRuleIdentifier] = useState<RuleIdentifier>();
|
||||
|
||||
const [deleteModal, showDeleteModal] = useDeleteModal();
|
||||
|
||||
const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
|
||||
const canEdit = editSupported && editAllowed;
|
||||
|
||||
const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
|
||||
const canDelete = deleteSupported && deleteAllowed;
|
||||
|
||||
const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
|
||||
const canDuplicate = duplicateSupported && duplicateAllowed;
|
||||
|
||||
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence);
|
||||
const canSilence = silenceSupported && silenceAllowed;
|
||||
|
||||
const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport);
|
||||
const canExport = exportSupported && exportAllowed;
|
||||
const actions = useAlertRulePageActions({
|
||||
handleDuplicateRule: setDuplicateRuleIdentifier,
|
||||
handleDelete: showDeleteModal,
|
||||
});
|
||||
|
||||
const promRule = rule.promRule;
|
||||
|
||||
@ -77,20 +59,6 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => {
|
||||
const isFederatedRule = isFederatedRuleGroup(rule.group);
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
|
||||
/**
|
||||
* Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana.
|
||||
* We should show it in development mode
|
||||
*/
|
||||
const shouldShowDeclareIncidentButton = !isOpenSourceEdition() || isLocalDevEnv();
|
||||
const shareUrl = createShareLink(rule.namespace.rulesSource, rule);
|
||||
|
||||
const copyShareUrl = () => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper
|
||||
pageNav={pageNav}
|
||||
@ -99,45 +67,7 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => {
|
||||
renderTitle={(title) => {
|
||||
return <Title name={title} state={isAlertType ? promRule.state : undefined} />;
|
||||
}}
|
||||
actions={[
|
||||
canEdit && <EditButton key="edit-action" identifier={identifier} />,
|
||||
<Dropdown
|
||||
key="more-actions"
|
||||
overlay={
|
||||
<Menu>
|
||||
{canSilence && (
|
||||
<Menu.Item
|
||||
label="Silence"
|
||||
icon="bell-slash"
|
||||
url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />}
|
||||
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" />}
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Copy link" icon="share-alt" onClick={copyShareUrl} />
|
||||
{canExport && (
|
||||
<Menu.Item
|
||||
label="Export"
|
||||
icon="download-alt"
|
||||
childItems={[
|
||||
<Menu.Item key="no-modifications" label="Without modifications" icon="file-blank" />,
|
||||
<Menu.Item key="with-modifications" label="With modifications" icon="file-alt" />,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => showDeleteModal(rule)} />
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<MoreButton size="md" />
|
||||
</Dropdown>,
|
||||
]}
|
||||
actions={actions}
|
||||
info={createMetadata(rule)}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
@ -168,26 +98,18 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => {
|
||||
</Stack>
|
||||
</Stack>
|
||||
{deleteModal}
|
||||
{duplicateRuleIdentifier && (
|
||||
<RedirectToCloneRule
|
||||
redirectTo={true}
|
||||
identifier={duplicateRuleIdentifier}
|
||||
isProvisioned={isProvisioned}
|
||||
onDismiss={() => setDuplicateRuleIdentifier(undefined)}
|
||||
/>
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditButtonProps {
|
||||
identifier: RuleIdentifier;
|
||||
}
|
||||
|
||||
export const EditButton = ({ identifier }: EditButtonProps) => {
|
||||
const returnTo = location.pathname + location.search;
|
||||
const ruleIdentifier = ruleId.stringifyIdentifier(identifier);
|
||||
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo });
|
||||
|
||||
return (
|
||||
<LinkButton variant="secondary" icon="pen" href={editURL}>
|
||||
Edit
|
||||
</LinkButton>
|
||||
);
|
||||
};
|
||||
|
||||
const createMetadata = (rule: CombinedRule): PageInfoItem[] => {
|
||||
const { labels, annotations, group } = rule;
|
||||
const metadata: PageInfoItem[] = [];
|
||||
|
@ -0,0 +1,58 @@
|
||||
import { rest } from 'msw';
|
||||
import { SetupServer, setupServer } from 'msw/node';
|
||||
|
||||
import 'whatwg-fetch';
|
||||
import { AlertmanagersChoiceResponse } from 'app/features/alerting/unified/api/alertmanagerApi';
|
||||
import { mockAlertmanagerChoiceResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = {
|
||||
alertmanagersChoice: AlertmanagerChoice.Internal,
|
||||
numExternalAlertmanagers: 0,
|
||||
};
|
||||
|
||||
const folderAccess = {
|
||||
[AccessControlAction.AlertingRuleCreate]: true,
|
||||
[AccessControlAction.AlertingRuleRead]: true,
|
||||
[AccessControlAction.AlertingRuleUpdate]: true,
|
||||
[AccessControlAction.AlertingRuleDelete]: true,
|
||||
};
|
||||
|
||||
export function createMockGrafanaServer() {
|
||||
const server = setupServer();
|
||||
|
||||
mockFolderAccess(server, folderAccess);
|
||||
mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse);
|
||||
mockGrafanaIncidentPluginSettings(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// this endpoint is used to determine of we have edit / delete permissions for the Grafana managed alert rule
|
||||
// a user must alsso have permissions for the folder (namespace) in which the alert rule is stored
|
||||
function mockFolderAccess(server: SetupServer, accessControl: Partial<Record<AccessControlAction, boolean>>) {
|
||||
server.use(
|
||||
rest.get('/api/folders/:uid', (req, res, ctx) => {
|
||||
const uid = req.params.uid;
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
title: 'My Folder',
|
||||
uid,
|
||||
accessControl,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function mockGrafanaIncidentPluginSettings(server: SetupServer) {
|
||||
server.use(
|
||||
rest.get('/api/plugins/grafana-incident-app/settings', (_, res, ctx) => {
|
||||
return res(ctx.status(200));
|
||||
})
|
||||
);
|
||||
}
|
@ -1,9 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { Redirect, useLocation } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, ConfirmModal, useStyles2 } from '@grafana/ui';
|
||||
import { Button, ConfirmModal } from '@grafana/ui';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
@ -11,19 +9,31 @@ import * as ruleId from '../../utils/rule-id';
|
||||
interface ConfirmCloneRuleModalProps {
|
||||
identifier: RuleIdentifier;
|
||||
isProvisioned: boolean;
|
||||
redirectTo?: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: ConfirmCloneRuleModalProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
export function RedirectToCloneRule({
|
||||
identifier,
|
||||
isProvisioned,
|
||||
redirectTo = false,
|
||||
onDismiss,
|
||||
}: ConfirmCloneRuleModalProps) {
|
||||
// For provisioned rules an additional confirmation step is required
|
||||
// Users have to be aware that the cloned rule will NOT be marked as provisioned
|
||||
const location = useLocation();
|
||||
const [stage, setStage] = useState<'redirect' | 'confirm'>(isProvisioned ? 'confirm' : 'redirect');
|
||||
|
||||
if (stage === 'redirect') {
|
||||
const cloneUrl = `/alerting/new?copyFrom=${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}`;
|
||||
return <Redirect to={cloneUrl} push />;
|
||||
const copyFrom = ruleId.stringifyIdentifier(identifier);
|
||||
const returnTo = location.pathname + location.search;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
copyFrom,
|
||||
returnTo: redirectTo ? returnTo : '',
|
||||
});
|
||||
|
||||
return <Redirect to={`/alerting/new?` + queryParams.toString()} push />;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -33,7 +43,7 @@ export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: Co
|
||||
body={
|
||||
<div>
|
||||
<p>
|
||||
The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule.
|
||||
The new rule will <strong>not</strong> be marked as a provisioned rule.
|
||||
</p>
|
||||
<p>
|
||||
You will need to set a new evaluation group for the copied rule because the original one has been
|
||||
@ -87,9 +97,3 @@ export const CloneRuleButton = React.forwardRef<HTMLButtonElement, CloneRuleButt
|
||||
);
|
||||
|
||||
CloneRuleButton.displayName = 'CloneRuleButton';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
bold: css`
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
`,
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user