Alerting: enforce roles on frontend (#33997)

This commit is contained in:
Domas 2021-05-17 11:15:17 +03:00 committed by GitHub
parent a26507e9c4
commit 8a0dbd0127
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 281 additions and 125 deletions

View File

@ -101,8 +101,8 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/playlists/", reqSignedIn, hs.Index) r.Get("/playlists/", reqSignedIn, hs.Index)
r.Get("/playlists/*", reqSignedIn, hs.Index) r.Get("/playlists/*", reqSignedIn, hs.Index)
r.Get("/alerting/", reqEditorRole, hs.Index) r.Get("/alerting/", reqSignedIn, hs.Index)
r.Get("/alerting/*", reqEditorRole, hs.Index) r.Get("/alerting/*", reqSignedIn, hs.Index)
// sign up // sign up
r.Get("/verify", hs.Index) r.Get("/verify", hs.Index)

View File

@ -214,6 +214,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Text: "Contact points", Id: "receivers", Url: hs.Cfg.AppSubURL + "/alerting/notifications", Text: "Contact points", Id: "receivers", Url: hs.Cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share", Icon: "comment-alt-share",
}) })
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Routes", Id: "am-routes", Url: hs.Cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"})
} else { } else {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{ alertChildNavs = append(alertChildNavs, &dtos.NavLink{
Text: "Notification channels", Id: "channels", Url: hs.Cfg.AppSubURL + "/alerting/notifications", Text: "Notification channels", Id: "channels", Url: hs.Cfg.AppSubURL + "/alerting/notifications",
@ -222,10 +223,6 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
} }
} }
if c.OrgRole == models.ROLE_ADMIN && hs.Cfg.IsNgAlertEnabled() {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Routes", Id: "am-routes", Url: hs.Cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"})
}
navTree = append(navTree, &dtos.NavLink{ navTree = append(navTree, &dtos.NavLink{
Text: "Alerting", Text: "Alerting",
SubTitle: "Alert rules and notifications", SubTitle: "Alert rules and notifications",

View File

@ -43,7 +43,9 @@ export const PanelAlertTabContent: FC<Props> = ({ dashboard, panel }) => {
<div className={styles.innerWrapper}> <div className={styles.innerWrapper}>
{alert} {alert}
<RulesTable rules={rules} /> <RulesTable rules={rules} />
{!!dashboard.meta.canSave && (
<NewRuleFromPanelButton className={styles.newButton} panel={panel} dashboard={dashboard} /> <NewRuleFromPanelButton className={styles.newButton} panel={panel} dashboard={dashboard} />
)}
</div> </div>
</CustomScrollbar> </CustomScrollbar>
); );
@ -52,12 +54,13 @@ export const PanelAlertTabContent: FC<Props> = ({ dashboard, panel }) => {
return ( return (
<div className={styles.noRulesWrapper}> <div className={styles.noRulesWrapper}>
{alert} {alert}
{!!dashboard.uid ? ( {!!dashboard.uid && (
<> <>
<p>There are no alert rules linked to this panel.</p> <p>There are no alert rules linked to this panel.</p>
<NewRuleFromPanelButton panel={panel} dashboard={dashboard} /> {!!dashboard.meta.canSave && <NewRuleFromPanelButton panel={panel} dashboard={dashboard} />}
</> </>
) : ( )}
{!dashboard.uid && !!dashboard.meta.canSave && (
<Alert severity="info" title="Dashboard not saved"> <Alert severity="info" title="Dashboard not saved">
Dashboard must be saved before alerts can be added. Dashboard must be saved before alerts can be added.
</Alert> </Alert>

View File

@ -16,6 +16,7 @@ import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants'; import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
import store from 'app/core/store'; import store from 'app/core/store';
import { contextSrv } from 'app/core/services/context_srv';
jest.mock('./api/alertmanager'); jest.mock('./api/alertmanager');
jest.mock('./api/grafana'); jest.mock('./api/grafana');
@ -95,6 +96,7 @@ describe('Receivers', () => {
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock); mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock);
setDataSourceSrv(new MockDataSourceSrv(dataSources)); setDataSourceSrv(new MockDataSourceSrv(dataSources));
contextSrv.isEditor = true;
store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY);
}); });

View File

@ -1,11 +1,15 @@
import { Alert, Button, LoadingPlaceholder } from '@grafana/ui'; import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LinkButton, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/services/context_srv';
import { useCleanup } from 'app/core/hooks/useCleanup'; import { useCleanup } from 'app/core/hooks/useCleanup';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { RuleIdentifier } from 'app/types/unified-alerting'; import { RuleIdentifier } from 'app/types/unified-alerting';
import React, { FC, useEffect } from 'react'; import React, { FC, useEffect } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchExistingRuleAction } from './state/actions'; import { fetchExistingRuleAction } from './state/actions';
import { parseRuleIdentifier } from './utils/rules'; import { parseRuleIdentifier } from './utils/rules';
@ -18,13 +22,15 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule); useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule);
const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule); const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { isEditable, loading: loadingEditableStatus } = useIsRuleEditable(result?.rule);
useEffect(() => { useEffect(() => {
if (!dispatched) { if (!dispatched) {
dispatch(fetchExistingRuleAction(identifier)); dispatch(fetchExistingRuleAction(identifier));
} }
}, [dispatched, dispatch, identifier]); }, [dispatched, dispatch, identifier]);
if (loading) { if (loading || loadingEditableStatus) {
return ( return (
<Page.Contents> <Page.Contents>
<LoadingPlaceholder text="Loading rule..." /> <LoadingPlaceholder text="Loading rule..." />
@ -41,16 +47,10 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
); );
} }
if (!result) { if (!result) {
return ( return <AlertWarning title="Rule not found">Sorry! This rule does not exist.</AlertWarning>;
<Page.Contents> }
<Alert severity="warning" title="Rule not found"> if (isEditable === false) {
<p>Sorry! This rule does not exist.</p> return <AlertWarning title="Cannot edit rule">Sorry! You do not have permission to edit this rule.</AlertWarning>;
<a href="/alerting/list">
<Button>To rule list</Button>
</a>
</Alert>
</Page.Contents>
);
} }
return <AlertRuleForm existing={result} />; return <AlertRuleForm existing={result} />;
}; };
@ -63,7 +63,23 @@ const RuleEditor: FC<RuleEditorProps> = ({ match }) => {
const identifier = parseRuleIdentifier(decodeURIComponent(id)); const identifier = parseRuleIdentifier(decodeURIComponent(id));
return <ExistingRuleEditor key={id} identifier={identifier} />; return <ExistingRuleEditor key={id} identifier={identifier} />;
} }
if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) {
return <AlertWarning title="Cannot create rules">Sorry! You are not allowed to create rules.</AlertWarning>;
}
return <AlertRuleForm />; return <AlertRuleForm />;
}; };
const AlertWarning: FC<{ title: string }> = ({ title, children }) => (
<Alert className={useStyles2(warningStyles).warning} severity="warning" title={title}>
<p>{children}</p>
<LinkButton href="alerting/list">To rule list</LinkButton>
</Alert>
);
const warningStyles = (theme: GrafanaTheme2) => ({
warning: css`
margin: ${theme.spacing(4)};
`,
});
export default RuleEditor; export default RuleEditor;

View File

@ -18,6 +18,7 @@ import { RuleListGroupView } from './components/rules/RuleListGroupView';
import { RuleListStateView } from './components/rules/RuleListStateView'; import { RuleListStateView } from './components/rules/RuleListStateView';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { contextSrv } from 'app/core/services/context_srv';
const VIEWS = { const VIEWS = {
groups: RuleListGroupView, groups: RuleListGroupView,
@ -128,12 +129,14 @@ export const RuleList: FC = () => {
</a> </a>
</ButtonGroup> </ButtonGroup>
<div /> <div />
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && (
<LinkButton <LinkButton
href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })} href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })}
icon="plus" icon="plus"
> >
New alert rule New alert rule
</LinkButton> </LinkButton>
)}
</div> </div>
</> </>
)} )}

View File

@ -16,7 +16,7 @@ import { saveRuleFormAction } from '../../state/actions';
import { RuleWithLocation } from 'app/types/unified-alerting'; import { RuleWithLocation } from 'app/types/unified-alerting';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useCleanup } from 'app/core/hooks/useCleanup'; import { useCleanup } from 'app/core/hooks/useCleanup';
import { rulerRuleToFormValues, defaultFormValues, getDefaultQueries } from '../../utils/rule-form'; import { rulerRuleToFormValues, getDefaultFormValues, getDefaultQueries } from '../../utils/rule-form';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
@ -36,7 +36,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
return rulerRuleToFormValues(existing); return rulerRuleToFormValues(existing);
} }
return { return {
...defaultFormValues, ...getDefaultFormValues(),
queries: getDefaultQueries(), queries: getDefaultQueries(),
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}), ...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
}; };

View File

@ -10,6 +10,7 @@ import { DataSourcePicker } from '@grafana/runtime';
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler'; import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
import { RuleFolderPicker } from './RuleFolderPicker'; import { RuleFolderPicker } from './RuleFolderPicker';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields'; import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { contextSrv } from 'app/core/services/context_srv';
const alertTypeOptions: SelectableValue[] = [ const alertTypeOptions: SelectableValue[] = [
{ {
@ -17,12 +18,15 @@ const alertTypeOptions: SelectableValue[] = [
value: RuleFormType.threshold, value: RuleFormType.threshold,
description: 'Metric alert based on a defined threshold', description: 'Metric alert based on a defined threshold',
}, },
{ ];
if (contextSrv.isEditor) {
alertTypeOptions.push({
label: 'System or application', label: 'System or application',
value: RuleFormType.system, value: RuleFormType.system,
description: 'Alert based on a system or application behavior. Based on Prometheus.', description: 'Alert based on a system or application behavior. Based on Prometheus.',
}, });
]; }
interface Props { interface Props {
editingExistingRule: boolean; editingExistingRule: boolean;

View File

@ -1,7 +1,11 @@
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { contextSrv } from 'app/core/services/context_srv';
import React, { FC } from 'react'; import React, { FC } from 'react';
import { CallToActionCard } from '@grafana/ui';
export const NoRulesSplash: FC = () => ( export const NoRulesSplash: FC = () => {
if (contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) {
return (
<EmptyListCTA <EmptyListCTA
title="You haven`t created any alert rules yet" title="You haven`t created any alert rules yet"
buttonIcon="bell" buttonIcon="bell"
@ -12,4 +16,7 @@ export const NoRulesSplash: FC = () => (
proTipLinkTitle="Learn more" proTipLinkTitle="Learn more"
proTipTarget="_blank" proTipTarget="_blank"
/> />
); );
}
return <CallToActionCard message="No rules exist yet." callToActionElement={<div />} />;
};

View File

@ -1,10 +1,12 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2, urlUtil } from '@grafana/data'; import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { Button, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; import { Button, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import React, { FC, useState } from 'react'; import React, { FC, useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
import { deleteRuleAction } from '../../state/actions'; import { deleteRuleAction } from '../../state/actions';
import { Annotation } from '../../utils/constants'; import { Annotation } from '../../utils/constants';
import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
@ -26,6 +28,8 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
const leftButtons: JSX.Element[] = []; const leftButtons: JSX.Element[] = [];
const rightButtons: JSX.Element[] = []; const rightButtons: JSX.Element[] = [];
const { isEditable } = useIsRuleEditable(rulerRule);
const deleteRule = () => { const deleteRule = () => {
if (ruleToDelete && ruleToDelete.rulerRule) { if (ruleToDelete && ruleToDelete.rulerRule) {
dispatch( dispatch(
@ -43,7 +47,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
}; };
// explore does not support grafana rule queries atm // explore does not support grafana rule queries atm
if (isCloudRulesSource(rulesSource)) { if (isCloudRulesSource(rulesSource) && contextSrv.isEditor) {
leftButtons.push( leftButtons.push(
<LinkButton <LinkButton
className={style.button} className={style.button}
@ -108,8 +112,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
} }
} }
// @TODO check roles if (isEditable && rulerRule) {
if (!!rulerRule) {
const editURL = urlUtil.renderUrl( const editURL = urlUtil.renderUrl(
`/alerting/${encodeURIComponent( `/alerting/${encodeURIComponent(
stringifyRuleIdentifier( stringifyRuleIdentifier(

View File

@ -13,6 +13,7 @@ import { ActionIcon } from './ActionIcon';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { useHasRuler } from '../../hooks/useHasRuler'; import { useHasRuler } from '../../hooks/useHasRuler';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import { useFolder } from '../../hooks/useFolder';
interface Props { interface Props {
namespace: CombinedRuleNamespace; namespace: CombinedRuleNamespace;
@ -26,6 +27,9 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const hasRuler = useHasRuler(); const hasRuler = useHasRuler();
const rulerRule = group.rules[0]?.rulerRule;
const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined;
const { folder } = useFolder(folderUID);
const stats = useMemo( const stats = useMemo(
(): Record<PromAlertingRuleState, number> => (): Record<PromAlertingRuleState, number> =>
@ -65,11 +69,14 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
// for grafana, link to folder views // for grafana, link to folder views
if (rulesSource === GRAFANA_RULES_SOURCE_NAME) { if (rulesSource === GRAFANA_RULES_SOURCE_NAME) {
const rulerRule = group.rules[0]?.rulerRule;
const folderUID = rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid;
if (folderUID) { if (folderUID) {
const baseUrl = `/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`; const baseUrl = `/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
actionIcons.push(<ActionIcon key="edit" icon="pen" tooltip="edit" to={baseUrl + '/settings'} target="__blank" />); if (folder?.canSave) {
actionIcons.push(
<ActionIcon key="edit" icon="pen" tooltip="edit" to={baseUrl + '/settings'} target="__blank" />
);
}
if (folder?.canAdmin) {
actionIcons.push( actionIcons.push(
<ActionIcon <ActionIcon
key="manage-perms" key="manage-perms"
@ -79,6 +86,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
target="__blank" target="__blank"
/> />
); );
}
} else if (hasRuler(rulesSource)) { } else if (hasRuler(rulesSource)) {
actionIcons.push(<ActionIcon key="edit" icon="pen" tooltip="edit" />); // @TODO actionIcons.push(<ActionIcon key="edit" icon="pen" tooltip="edit" />); // @TODO
} }

View File

@ -1,4 +1,6 @@
import { CallToActionCard } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { contextSrv } from 'app/core/services/context_srv';
import React, { FC } from 'react'; import React, { FC } from 'react';
import { makeAMLink } from '../../utils/misc'; import { makeAMLink } from '../../utils/misc';
@ -6,11 +8,16 @@ type Props = {
alertManagerSourceName: string; alertManagerSourceName: string;
}; };
export const NoSilencesSplash: FC<Props> = ({ alertManagerSourceName }) => ( export const NoSilencesSplash: FC<Props> = ({ alertManagerSourceName }) => {
if (contextSrv.isEditor) {
return (
<EmptyListCTA <EmptyListCTA
title="You haven't created any silences yet" title="You haven't created any silences yet"
buttonIcon="bell-slash" buttonIcon="bell-slash"
buttonLink={makeAMLink('alerting/silence/new', alertManagerSourceName)} buttonLink={makeAMLink('alerting/silence/new', alertManagerSourceName)}
buttonTitle="New silence" buttonTitle="New silence"
/> />
); );
}
return <CallToActionCard callToActionElement={<div />} message="No silences found." />;
};

View File

@ -12,6 +12,7 @@ import { useDispatch } from 'react-redux';
import { Matchers } from './Matchers'; import { Matchers } from './Matchers';
import { SilenceStateTag } from './SilenceStateTag'; import { SilenceStateTag } from './SilenceStateTag';
import { makeAMLink } from '../../utils/misc'; import { makeAMLink } from '../../utils/misc';
import { contextSrv } from 'app/core/services/context_srv';
interface Props { interface Props {
className?: string; className?: string;
silence: Silence; silence: Silence;
@ -35,6 +36,8 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
dispatch(expireSilenceAction(alertManagerSourceName, silence.id)); dispatch(expireSilenceAction(alertManagerSourceName, silence.id));
}; };
const detailsColspan = contextSrv.isEditor ? 4 : 3;
return ( return (
<Fragment> <Fragment>
<tr className={className}> <tr className={className}>
@ -53,6 +56,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
<br /> <br />
{endsAtDate?.format(dateDisplayFormat)} {endsAtDate?.format(dateDisplayFormat)}
</td> </td>
{contextSrv.isEditor && (
<td className={styles.actionsCell}> <td className={styles.actionsCell}>
{status.state === 'expired' ? ( {status.state === 'expired' ? (
<Link href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}> <Link href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}>
@ -71,36 +75,37 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
/> />
)} )}
</td> </td>
)}
</tr> </tr>
{!isCollapsed && ( {!isCollapsed && (
<> <>
<tr className={className}> <tr className={className}>
<td /> <td />
<td>Comment</td> <td>Comment</td>
<td colSpan={4}>{comment}</td> <td colSpan={detailsColspan}>{comment}</td>
</tr> </tr>
<tr className={className}> <tr className={className}>
<td /> <td />
<td>Schedule</td> <td>Schedule</td>
<td colSpan={4}>{`${startsAtDate?.format(dateDisplayFormat)} - ${endsAtDate?.format( <td colSpan={detailsColspan}>{`${startsAtDate?.format(dateDisplayFormat)} - ${endsAtDate?.format(
dateDisplayFormat dateDisplayFormat
)}`}</td> )}`}</td>
</tr> </tr>
<tr className={className}> <tr className={className}>
<td /> <td />
<td>Duration</td> <td>Duration</td>
<td colSpan={4}>{duration}</td> <td colSpan={detailsColspan}>{duration}</td>
</tr> </tr>
<tr className={className}> <tr className={className}>
<td /> <td />
<td>Created by</td> <td>Created by</td>
<td colSpan={4}>{createdBy}</td> <td colSpan={detailsColspan}>{createdBy}</td>
</tr> </tr>
{!!silencedAlerts.length && ( {!!silencedAlerts.length && (
<tr className={cx(className, styles.alertRulesCell)}> <tr className={cx(className, styles.alertRulesCell)}>
<td /> <td />
<td>Affected alerts</td> <td>Affected alerts</td>
<td colSpan={4}> <td colSpan={detailsColspan}>
<SilencedAlertsTable silencedAlerts={silencedAlerts} /> <SilencedAlertsTable silencedAlerts={silencedAlerts} />
</td> </td>
</tr> </tr>

View File

@ -7,6 +7,7 @@ import SilenceTableRow from './SilenceTableRow';
import { getAlertTableStyles } from '../../styles/table'; import { getAlertTableStyles } from '../../styles/table';
import { NoSilencesSplash } from './NoSilencesCTA'; import { NoSilencesSplash } from './NoSilencesCTA';
import { makeAMLink } from '../../utils/misc'; import { makeAMLink } from '../../utils/misc';
import { contextSrv } from 'app/core/services/context_srv';
interface Props { interface Props {
silences: Silence[]; silences: Silence[];
alertManagerAlerts: AlertmanagerAlert[]; alertManagerAlerts: AlertmanagerAlert[];
@ -25,6 +26,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
<> <>
{!!silences.length && ( {!!silences.length && (
<> <>
{contextSrv.isEditor && (
<div className={styles.topButtonContainer}> <div className={styles.topButtonContainer}>
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}> <Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
<Button className={styles.addNewSilence} icon="plus"> <Button className={styles.addNewSilence} icon="plus">
@ -32,6 +34,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
</Button> </Button>
</Link> </Link>
</div> </div>
)}
<table className={tableStyles.table}> <table className={tableStyles.table}>
<colgroup> <colgroup>
<col className={tableStyles.colExpand} /> <col className={tableStyles.colExpand} />
@ -39,7 +42,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
<col className={styles.colMatchers} /> <col className={styles.colMatchers} />
<col /> <col />
<col /> <col />
<col /> {contextSrv.isEditor && <col />}
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
@ -48,7 +51,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
<th>Matchers</th> <th>Matchers</th>
<th>Alerts</th> <th>Alerts</th>
<th>Schedule</th> <th>Schedule</th>
<th>Action</th> {contextSrv.isEditor && <th>Action</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -0,0 +1,32 @@
import { FolderDTO } from 'app/types';
import { useDispatch } from 'react-redux';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
import { useEffect } from 'react';
import { fetchFolderIfNotFetchedAction } from '../state/actions';
import { initialAsyncRequestState } from '../utils/redux';
interface ReturnBag {
folder?: FolderDTO;
loading: boolean;
}
export function useFolder(uid?: string): ReturnBag {
const dispatch = useDispatch();
const folderRequests = useUnifiedAlertingSelector((state) => state.folders);
useEffect(() => {
if (uid) {
dispatch(fetchFolderIfNotFetchedAction(uid));
}
}, [dispatch, uid]);
if (uid) {
const request = folderRequests[uid] || initialAsyncRequestState;
return {
folder: request.result,
loading: request.loading,
};
}
return {
loading: false,
};
}

View File

@ -0,0 +1,38 @@
import { contextSrv } from 'app/core/services/context_srv';
import { isGrafanaRulerRule } from '../utils/rules';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { useFolder } from './useFolder';
interface ResultBag {
isEditable?: boolean;
loading: boolean;
}
export function useIsRuleEditable(rule?: RulerRuleDTO): ResultBag {
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
const { folder, loading } = useFolder(folderUID);
if (!rule) {
return { isEditable: false, loading: false };
}
// grafana rules can be edited if user can edit the folder they're in
if (isGrafanaRulerRule(rule)) {
if (!folderUID) {
throw new Error(
`Rule ${rule.grafana_alert.title} does not have a folder uid, cannot determine if it is editable.`
);
}
return {
isEditable: folder?.canSave,
loading,
};
}
// prom rules are only editable by users with Editor role
return {
isEditable: contextSrv.isEditor,
loading: false,
};
}

View File

@ -8,7 +8,7 @@ import {
Silence, Silence,
SilenceCreatePayload, SilenceCreatePayload,
} from 'app/plugins/datasource/alertmanager/types'; } from 'app/plugins/datasource/alertmanager/types';
import { NotifierDTO, ThunkResult } from 'app/types'; import { FolderDTO, NotifierDTO, ThunkResult } from 'app/types';
import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting'; import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting';
import { import {
PostableRulerRuleGroupDTO, PostableRulerRuleGroupDTO,
@ -48,6 +48,7 @@ import {
stringifyRuleIdentifier, stringifyRuleIdentifier,
} from '../utils/rules'; } from '../utils/rules';
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager-config'; import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager-config';
import { backendSrv } from 'app/core/services/backend_srv';
export const fetchPromRulesAction = createAsyncThunk( export const fetchPromRulesAction = createAsyncThunk(
'unifiedalerting/fetchPromRules', 'unifiedalerting/fetchPromRules',
@ -460,3 +461,16 @@ export const deleteTemplateAction = (templateName: string, alertManagerSourceNam
); );
}; };
}; };
export const fetchFolderAction = createAsyncThunk(
'unifiedalerting/fetchFolder',
(uid: string): Promise<FolderDTO> => withSerializedError(backendSrv.getFolderByUid(uid))
);
export const fetchFolderIfNotFetchedAction = (uid: string): ThunkResult<void> => {
return (dispatch, getState) => {
if (!getState().unifiedAlerting.folders[uid]?.dispatched) {
dispatch(fetchFolderAction(uid));
}
};
};

View File

@ -11,6 +11,7 @@ import {
saveRuleFormAction, saveRuleFormAction,
updateAlertManagerConfigAction, updateAlertManagerConfigAction,
createOrUpdateSilenceAction, createOrUpdateSilenceAction,
fetchFolderAction,
} from './actions'; } from './actions';
export const reducer = combineReducers({ export const reducer = combineReducers({
@ -32,6 +33,7 @@ export const reducer = combineReducers({
updateSilence: createAsyncSlice('updateSilence', createOrUpdateSilenceAction).reducer, updateSilence: createAsyncSlice('updateSilence', createOrUpdateSilenceAction).reducer,
amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName) amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName)
.reducer, .reducer,
folders: createAsyncMapSlice('folders', fetchFolderAction, (uid) => uid).reducer,
}); });
export type UnifiedAlertingState = ReturnType<typeof reducer>; export type UnifiedAlertingState = ReturnType<typeof reducer>;

View File

@ -1,5 +1,6 @@
import { DataQuery, getDefaultTimeRange, rangeUtil, RelativeTimeRange } from '@grafana/data'; import { DataQuery, getDefaultTimeRange, rangeUtil, RelativeTimeRange } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { getNextRefIdChar } from 'app/core/utils/query'; import { getNextRefIdChar } from 'app/core/utils/query';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { ExpressionDatasourceID, ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource'; import { ExpressionDatasourceID, ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
@ -22,11 +23,13 @@ import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule } from './rules'; import { isAlertingRulerRule, isGrafanaRulerRule } from './rules';
import { parseInterval } from './time'; import { parseInterval } from './time';
export const defaultFormValues: RuleFormValues = Object.freeze({ export const getDefaultFormValues = (): RuleFormValues =>
Object.freeze({
name: '', name: '',
labels: [{ key: '', value: '' }], labels: [{ key: '', value: '' }],
annotations: [{ key: '', value: '' }], annotations: [{ key: '', value: '' }],
dataSourceName: null, dataSourceName: null,
type: !contextSrv.isEditor ? RuleFormType.threshold : undefined, // viewers can't create prom alerts
// threshold // threshold
folder: null, folder: null,
@ -43,7 +46,7 @@ export const defaultFormValues: RuleFormValues = Object.freeze({
expression: '', expression: '',
forTime: 1, forTime: 1,
forTimeUnit: 'm', forTimeUnit: 'm',
}); });
export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerAlertingRuleDTO { export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerAlertingRuleDTO {
const { name, expression, forTime, forTimeUnit } = values; const { name, expression, forTime, forTimeUnit } = values;
@ -81,6 +84,8 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues { export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues {
const { ruleSourceName, namespace, group, rule } = ruleWithLocation; const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
const defaultFormValues = getDefaultFormValues();
if (isGrafanaRulesSource(ruleSourceName)) { if (isGrafanaRulesSource(ruleSourceName)) {
if (isGrafanaRulerRule(rule)) { if (isGrafanaRulerRule(rule)) {
const ga = rule.grafana_alert; const ga = rule.grafana_alert;

View File

@ -22,15 +22,15 @@
}, },
{ {
"method": "POST", "method": "POST",
"reqRole": "Admin" "reqRole": "Editor"
}, },
{ {
"method": "PUT", "method": "PUT",
"reqRole": "Admin" "reqRole": "Editor"
}, },
{ {
"method": "DELETE", "method": "DELETE",
"reqRole": "Admin" "reqRole": "Editor"
}, },
{ {
"method": "GET", "method": "GET",

View File

@ -350,7 +350,7 @@ export function getAppRoutes(): RouteDescriptor[] {
}, },
{ {
path: '/alerting/routes', path: '/alerting/routes',
roles: () => ['Admin'], roles: () => ['Admin', 'Editor'],
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes') () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes')
), ),
@ -363,42 +363,49 @@ export function getAppRoutes(): RouteDescriptor[] {
}, },
{ {
path: '/alerting/silence/new', path: '/alerting/silence/new',
roles: () => ['Editor', 'Admin'],
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
), ),
}, },
{ {
path: '/alerting/silence/:id/edit', path: '/alerting/silence/:id/edit',
roles: () => ['Editor', 'Admin'],
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
), ),
}, },
{ {
path: '/alerting/notifications', path: '/alerting/notifications',
roles: config.featureToggles.ngalert ? () => ['Editor', 'Admin'] : undefined,
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
), ),
}, },
{ {
path: '/alerting/notifications/templates/new', path: '/alerting/notifications/templates/new',
roles: () => ['Editor', 'Admin'],
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
), ),
}, },
{ {
path: '/alerting/notifications/templates/:id/edit', path: '/alerting/notifications/templates/:id/edit',
roles: () => ['Editor', 'Admin'],
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
), ),
}, },
{ {
path: '/alerting/notifications/receivers/new', path: '/alerting/notifications/receivers/new',
roles: () => ['Editor', 'Admin'],
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
), ),
}, },
{ {
path: '/alerting/notifications/receivers/:id/edit', path: '/alerting/notifications/receivers/:id/edit',
roles: () => ['Editor', 'Admin'],
component: SafeDynamicImport( component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
), ),