diff --git a/public/app/features/alerting/AlertTabIndex.tsx b/public/app/features/alerting/AlertTabIndex.tsx new file mode 100644 index 00000000000..c952c2bdd17 --- /dev/null +++ b/public/app/features/alerting/AlertTabIndex.tsx @@ -0,0 +1,7 @@ +import { config } from '@grafana/runtime'; +import { AlertTab } from './AlertTab'; +import { PanelAlertTabContent } from './unified/PanelAlertTabContent'; + +// route between unified and "old" alerting pages based on feature flag + +export default config.featureToggles.ngalert ? PanelAlertTabContent : AlertTab; diff --git a/public/app/features/alerting/unified/PanelAlertTab.tsx b/public/app/features/alerting/unified/PanelAlertTab.tsx new file mode 100644 index 00000000000..cb45af4cd13 --- /dev/null +++ b/public/app/features/alerting/unified/PanelAlertTab.tsx @@ -0,0 +1,15 @@ +import { Tab, TabProps } from '@grafana/ui/src/components/Tabs/Tab'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import React, { FC } from 'react'; +import { usePanelCombinedRules } from './hooks/usePanelCombinedRules'; + +interface Props extends Omit { + panel: PanelModel; + dashboard: DashboardModel; +} + +// it will load rule count from backend +export const PanelAlertTab: FC = ({ panel, dashboard, ...otherProps }) => { + const { rules, loading } = usePanelCombinedRules({ panel, dashboard }); + return ; +}; diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.tsx new file mode 100644 index 00000000000..1df8ad0689a --- /dev/null +++ b/public/app/features/alerting/unified/PanelAlertTabContent.tsx @@ -0,0 +1,81 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, CustomScrollbar, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import React, { FC } from 'react'; +import { NewRuleFromPanelButton } from './components/panel-alerts-tab/NewRuleFromPanelButton'; +import { RulesTable } from './components/rules/RulesTable'; +import { usePanelCombinedRules } from './hooks/usePanelCombinedRules'; + +interface Props { + dashboard: DashboardModel; + panel: PanelModel; +} + +export const PanelAlertTabContent: FC = ({ dashboard, panel }) => { + const styles = useStyles2(getStyles); + const { errors, loading, rules } = usePanelCombinedRules({ + dashboard, + panel, + poll: true, + }); + + const alert = errors.length ? ( + + {errors.map((error, index) => ( +
Failed to load Grafana threshold rules state: {error.message || 'Unknown error.'}
+ ))} +
+ ) : null; + + if (loading && !rules.length) { + return ( +
+ {alert} + +
+ ); + } + + if (rules.length) { + return ( + +
+ {alert} + + +
+
+ ); + } + + return ( +
+ {alert} + {!!dashboard.uid ? ( + <> +

There are no alert rules linked to this panel.

+ + + ) : ( + + Dashboard must be saved before alerts can be added. + + )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + newButton: css` + margin-top: ${theme.spacing(3)}; + `, + innerWrapper: css` + padding: ${theme.spacing(2)}; + `, + noRulesWrapper: css` + margin: ${theme.spacing(2)}; + background-color: ${theme.colors.background.secondary}; + padding: ${theme.spacing(3)}; + `, +}); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index e7a0387f41b..3de562a51ea 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -1,5 +1,5 @@ import { DataSourceInstanceSettings, GrafanaTheme, urlUtil } from '@grafana/data'; -import { useStyles, Button, ButtonGroup, ToolbarButton, Alert } from '@grafana/ui'; +import { useStyles, ButtonGroup, ToolbarButton, Alert, LinkButton } from '@grafana/ui'; import { SerializedError } from '@reduxjs/toolkit'; import React, { FC, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; @@ -17,6 +17,7 @@ import RulesFilter from './components/rules/RulesFilter'; import { RuleListGroupView } from './components/rules/RuleListGroupView'; import { RuleListStateView } from './components/rules/RuleListStateView'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { useLocation } from 'react-router-dom'; const VIEWS = { groups: RuleListGroupView, @@ -27,6 +28,7 @@ export const RuleList: FC = () => { const dispatch = useDispatch(); const styles = useStyles(getStyles); const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); + const location = useLocation(); const [queryParams] = useQueryParams(); @@ -126,9 +128,12 @@ export const RuleList: FC = () => {
- - - + + New alert rule +
)} diff --git a/public/app/features/alerting/unified/components/Annotation.tsx b/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx similarity index 51% rename from public/app/features/alerting/unified/components/Annotation.tsx rename to public/app/features/alerting/unified/components/AnnotationDetailsField.tsx index 548910ee998..ed27208dec9 100644 --- a/public/app/features/alerting/unified/components/Annotation.tsx +++ b/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx @@ -2,7 +2,9 @@ import React, { FC } from 'react'; import { Well } from './Well'; import { GrafanaTheme } from '@grafana/data'; import { css } from '@emotion/css'; -import { useStyles } from '@grafana/ui'; +import { Tooltip, useStyles } from '@grafana/ui'; +import { DetailsField } from './DetailsField'; +import { Annotation, annotationLabels } from '../utils/constants'; const wellableAnnotationKeys = ['message', 'description']; @@ -11,7 +13,23 @@ interface Props { value: string; } -export const Annotation: FC = ({ annotationKey, value }) => { +export const AnnotationDetailsField: FC = ({ annotationKey, value }) => { + const label = annotationLabels[annotationKey as Annotation] ? ( + + {annotationLabels[annotationKey as Annotation]} + + ) : ( + annotationKey + ); + + return ( + + + + ); +}; + +const AnnotationValue: FC = ({ annotationKey, value }) => { const styles = useStyles(getStyles); if (wellableAnnotationKeys.includes(annotationKey)) { return {value}; diff --git a/public/app/features/alerting/unified/components/DetailsField.tsx b/public/app/features/alerting/unified/components/DetailsField.tsx index e87fb7222d3..52f59f02603 100644 --- a/public/app/features/alerting/unified/components/DetailsField.tsx +++ b/public/app/features/alerting/unified/components/DetailsField.tsx @@ -36,7 +36,7 @@ const getStyles = (theme: GrafanaTheme) => ({ padding-right: ${theme.spacing.sm}; font-size: ${theme.typography.size.sm}; font-weight: ${theme.typography.weight.semibold}; - line-height: ${theme.typography.lineHeight.lg}; + line-height: 1.8; } & > div:nth-child(2) { flex: 1; diff --git a/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx new file mode 100644 index 00000000000..fb11e355d08 --- /dev/null +++ b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx @@ -0,0 +1,36 @@ +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import React, { FC } from 'react'; +import { Alert, LinkButton } from '@grafana/ui'; +import { panelToRuleFormValues } from '../../utils/rule-form'; +import { useLocation } from 'react-router-dom'; +import { urlUtil } from '@grafana/data'; + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; + className?: string; +} + +export const NewRuleFromPanelButton: FC = ({ dashboard, panel, className }) => { + const formValues = panelToRuleFormValues(panel, dashboard); + const location = useLocation(); + + if (!formValues) { + return ( + + Cannot create alerts from this panel because no query to an alerting capable datasource is found. + + ); + } + + const ruleFormUrl = urlUtil.renderUrl('alerting/new', { + defaults: JSON.stringify(formValues), + returnTo: location.pathname + location.search, + }); + + return ( + + Create alert rule from this panel + + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index 9f27dc83dc0..f413969cfea 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -18,6 +18,7 @@ import { useDispatch } from 'react-redux'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { rulerRuleToFormValues, defaultFormValues, getDefaultQueries } from '../../utils/rule-form'; import { Link } from 'react-router-dom'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; type Props = { existing?: RuleWithLocation; @@ -26,6 +27,9 @@ type Props = { export const AlertRuleForm: FC = ({ existing }) => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); + const [queryParams] = useQueryParams(); + + const returnTo: string = (queryParams['returnTo'] as string | undefined) ?? '/alerting/list'; const defaultValues: RuleFormValues = useMemo(() => { if (existing) { @@ -34,8 +38,9 @@ export const AlertRuleForm: FC = ({ existing }) => { return { ...defaultFormValues, queries: getDefaultQueries(), + ...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}), }; - }, [existing]); + }, [existing, queryParams]); const formAPI = useForm({ mode: 'onSubmit', @@ -68,7 +73,7 @@ export const AlertRuleForm: FC = ({ existing }) => { labels: values.labels?.filter(({ key }) => !!key) ?? [], }, existing, - exitOnSave, + redirectOnSave: exitOnSave ? returnTo : undefined, }) ); }; @@ -77,7 +82,7 @@ export const AlertRuleForm: FC = ({ existing }) => {
submit(values, false))} className={styles.form}> - + diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx index a6b94aabf77..270cb9b7f67 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx @@ -1,13 +1,7 @@ import { SelectableValue } from '@grafana/data'; import React, { FC, useMemo } from 'react'; import { SelectWithAdd } from './SelectWIthAdd'; - -enum AnnotationOptions { - description = 'Description', - dashboard = 'Dashboard', - summary = 'Summary', - runbook = 'Runbook URL', -} +import { Annotation, annotationLabels } from '../../utils/constants'; interface Props { onChange: (value: string) => void; @@ -21,9 +15,9 @@ interface Props { export const AnnotationKeyInput: FC = ({ value, existingKeys, ...rest }) => { const annotationOptions = useMemo( (): SelectableValue[] => - Object.entries(AnnotationOptions) - .filter(([optKey]) => !existingKeys.includes(optKey)) // remove keys already taken in other annotations - .map(([key, value]) => ({ value: key, label: value })), + Object.values(Annotation) + .filter((key) => !existingKeys.includes(key)) // remove keys already taken in other annotations + .map((key) => ({ value: key, label: annotationLabels[key] })), [existingKeys] ); @@ -31,7 +25,7 @@ export const AnnotationKeyInput: FC = ({ value, existingKeys, ...rest }) ); diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx index 037b9cd5404..58fbbc51a24 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx @@ -29,7 +29,7 @@ const AnnotationsField: FC = () => { return (
{fields.map((field, index) => ( -
+
{ ( - + )} control={control} rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }} @@ -89,7 +89,7 @@ const AnnotationsField: FC = () => { const getStyles = (theme: GrafanaTheme) => ({ annotationTextArea: css` - width: 450px; + width: 426px; height: 76px; `, addAnnotationsButton: css` diff --git a/public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx b/public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx index 4d79db696c9..16f41f99ad5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx @@ -1,5 +1,6 @@ import { SelectableValue } from '@grafana/data'; import { Field, InputControl, Select } from '@grafana/ui'; +import { ExpressionDatasourceID } from 'app/features/expressions/ExpressionDatasource'; import React, { FC, useEffect, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { RuleFormValues } from '../../types/rule-form'; @@ -25,12 +26,15 @@ export const ConditionField: FC = () => { [queries] ); - // if option no longer exists, reset it + // reset condition if option no longer exists or if it is unset, but there are options available useEffect(() => { + const expressions = queries.filter((query) => query.model.datasource === ExpressionDatasourceID); if (condition && !options.find(({ value }) => value === condition)) { - setValue('condition', null); + setValue('condition', expressions.length ? expressions[expressions.length - 1].refId : null); + } else if (!condition && expressions.length) { + setValue('condition', expressions[expressions.length - 1].refId); } - }, [condition, options, setValue]); + }, [condition, options, queries, setValue]); return ( = ({ className }) => { return ( <>
- Labels + Labels
{fields.map((field, index) => { return ( @@ -128,7 +128,7 @@ const getStyles = (theme: GrafanaTheme) => { margin-left: ${theme.spacing.xs}; `, labelInput: css` - width: 207px; + width: 183px; margin-bottom: ${theme.spacing.sm}; & + & { margin-left: ${theme.spacing.sm}; diff --git a/public/app/features/alerting/unified/components/rules/AlertInstanceDetails.tsx b/public/app/features/alerting/unified/components/rules/AlertInstanceDetails.tsx index 292c4e4f61a..cdd5c5ba0cb 100644 --- a/public/app/features/alerting/unified/components/rules/AlertInstanceDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/AlertInstanceDetails.tsx @@ -1,6 +1,6 @@ import { Alert } from 'app/types/unified-alerting'; import React, { FC } from 'react'; -import { Annotation } from '../Annotation'; +import { AnnotationDetailsField } from '../AnnotationDetailsField'; import { DetailsField } from '../DetailsField'; interface Props { @@ -18,9 +18,7 @@ export const AlertInstanceDetails: FC = ({ instance }) => { )} {annotations.map(([key, value]) => ( - - - + ))}
); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index f5f60ebae45..4c1bea8ac10 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -5,13 +5,14 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme } from '@grafana/data'; import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; import { isCloudRulesSource } from '../../utils/datasource'; -import { Annotation } from '../Annotation'; +import { AnnotationDetailsField } from '../AnnotationDetailsField'; import { AlertLabels } from '../AlertLabels'; import { AlertInstancesTable } from './AlertInstancesTable'; import { DetailsField } from '../DetailsField'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource'; import { Expression } from '../Expression'; +import { RuleDetailsActionButtons } from './RuleDetailsActionButtons'; interface Props { rule: CombinedRule; @@ -50,6 +51,7 @@ export const RuleDetails: FC = ({ rule, rulesSource }) => { return (
+
{!!rule.labels && !!Object.keys(rule.labels).length && ( @@ -67,9 +69,7 @@ export const RuleDetails: FC = ({ rule, rulesSource }) => { )} {annotations.map(([key, value]) => ( - - - + ))}
diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx new file mode 100644 index 00000000000..97ce5f25d7b --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -0,0 +1,178 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { Button, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; +import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import React, { FC, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { deleteRuleAction } from '../../state/actions'; +import { Annotation } from '../../utils/constants'; +import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; +import { createExploreLink } from '../../utils/misc'; +import { getRuleIdentifier, stringifyRuleIdentifier } from '../../utils/rules'; + +interface Props { + rule: CombinedRule; + rulesSource: RulesSource; +} + +export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { + const dispatch = useDispatch(); + const location = useLocation(); + const style = useStyles2(getStyles); + const { namespace, group, rulerRule } = rule; + const [ruleToDelete, setRuleToDelete] = useState(); + + const leftButtons: JSX.Element[] = []; + const rightButtons: JSX.Element[] = []; + + const deleteRule = () => { + if (ruleToDelete && ruleToDelete.rulerRule) { + dispatch( + deleteRuleAction( + getRuleIdentifier( + getRulesSourceName(ruleToDelete.namespace.rulesSource), + ruleToDelete.namespace.name, + ruleToDelete.group.name, + ruleToDelete.rulerRule + ) + ) + ); + setRuleToDelete(undefined); + } + }; + + // explore does not support grafana rule queries atm + if (isCloudRulesSource(rulesSource)) { + leftButtons.push( + + See graph + + ); + } + if (rule.annotations[Annotation.runbookURL]) { + leftButtons.push( + + View runbook + + ); + } + if (rule.annotations[Annotation.dashboardUID]) { + const dashboardUID = rule.annotations[Annotation.dashboardUID]; + if (dashboardUID) { + leftButtons.push( + + Go to dashboard + + ); + const panelId = rule.annotations[Annotation.panelID]; + if (panelId) { + leftButtons.push( + + Go to panel + + ); + } + } + } + + // @TODO check roles + if (!!rulerRule) { + const editURL = urlUtil.renderUrl( + `/alerting/${encodeURIComponent( + stringifyRuleIdentifier( + getRuleIdentifier(getRulesSourceName(rulesSource), namespace.name, group.name, rulerRule) + ) + )}/edit`, + { + returnTo: location.pathname + location.search, + } + ); + + rightButtons.push( + + Edit + , + + ); + } + if (leftButtons.length || rightButtons.length) { + return ( + <> +
+ {leftButtons.length ? leftButtons :
} + {rightButtons.length ? rightButtons :
} +
+ {!!ruleToDelete && ( + setRuleToDelete(undefined)} + /> + )} + + ); + } + + return null; +}; + +export const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + padding: ${theme.spacing(2)} 0; + display: flex; + flex-direction: row; + justify-content: space-between; + border-bottom: solid 1px ${theme.colors.border.medium}; + `, + button: css` + height: 24px; + font-size: ${theme.typography.size.sm}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx index e2a63dc27f8..3dfd9dc8c86 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import { GrafanaTheme } from '@grafana/data'; -import { useStyles } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { CombinedRule } from 'app/types/unified-alerting'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import React, { FC, useState } from 'react'; @@ -16,7 +16,7 @@ interface Props { export const RuleListStateSection: FC = ({ rules, state, defaultCollapsed = false }) => { const [collapsed, setCollapsed] = useState(defaultCollapsed); - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); return ( <>

@@ -28,16 +28,19 @@ export const RuleListStateSection: FC = ({ rules, state, defaultCollapsed /> {alertStateToReadable(state)} ({rules.length})

- {!collapsed && } + {!collapsed && } ); }; -const getStyles = (theme: GrafanaTheme) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ collapseToggle: css` vertical-align: middle; `, header: css` - margin-top: ${theme.spacing.md}; + margin-top: ${theme.spacing(2)}; + `, + rulesTable: css` + margin-top: ${theme.spacing(3)}; `, }); diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index 46f6e048587..e4ab3dc9789 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,7 +1,7 @@ import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import React, { FC, useMemo, useState, Fragment } from 'react'; -import { Icon, Tooltip, useStyles } from '@grafana/ui'; -import { GrafanaTheme } from '@grafana/data'; +import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; import { css } from '@emotion/css'; import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; @@ -21,7 +21,7 @@ interface Props { export const RulesGroup: FC = React.memo(({ group, namespace }) => { const { rulesSource } = namespace; - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const [isCollapsed, setIsCollapsed] = useState(true); @@ -122,25 +122,25 @@ export const RulesGroup: FC = React.memo(({ group, namespace }) => { )}
- {!isCollapsed && } + {!isCollapsed && }
); }); RulesGroup.displayName = 'RulesGroup'; -export const getStyles = (theme: GrafanaTheme) => ({ +export const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` & + & { - margin-top: ${theme.spacing.md}; + margin-top: ${theme.spacing(2)}; } `, header: css` display: flex; flex-direction: row; align-items: center; - padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} 0; - background-color: ${theme.colors.bg2}; + padding: ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0; + background-color: ${theme.colors.background.secondary}; `, headerStats: css` span { @@ -148,7 +148,7 @@ export const getStyles = (theme: GrafanaTheme) => ({ } `, heading: css` - margin-left: ${theme.spacing.sm}; + margin-left: ${theme.spacing(1)}; margin-bottom: 0; `, spacer: css` @@ -157,28 +157,31 @@ export const getStyles = (theme: GrafanaTheme) => ({ collapseToggle: css` background: none; border: none; - margin-top: -${theme.spacing.sm}; - margin-bottom: -${theme.spacing.sm}; + margin-top: -${theme.spacing(1)}; + margin-bottom: -${theme.spacing(1)}; svg { margin-bottom: 0; } `, dataSourceIcon: css` - width: ${theme.spacing.md}; - height: ${theme.spacing.md}; - margin-left: ${theme.spacing.md}; + width: ${theme.spacing(2)}; + height: ${theme.spacing(2)}; + margin-left: ${theme.spacing(2)}; `, dataSourceOrigin: css` margin-right: 1em; - color: ${theme.colors.textFaint}; + color: ${theme.colors.text.disabled}; `, actionsSeparator: css` - margin: 0 ${theme.spacing.sm}; + margin: 0 ${theme.spacing(2)}; `, actionIcons: css` & > * + * { - margin-left: ${theme.spacing.sm}; + margin-left: ${theme.spacing(1)}; } `, + rulesTable: css` + margin-top: ${theme.spacing(3)}; + `, }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index be39b592859..39084d46222 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -1,16 +1,12 @@ import { GrafanaTheme2 } from '@grafana/data'; -import { ConfirmModal, useStyles2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import React, { FC, Fragment, useState } from 'react'; -import { getRuleIdentifier, isAlertingRule, isRecordingRule, stringifyRuleIdentifier } from '../../utils/rules'; +import { isAlertingRule, isRecordingRule } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; import { css, cx } from '@emotion/css'; import { RuleDetails } from './RuleDetails'; import { getAlertTableStyles } from '../../styles/table'; -import { ActionIcon } from './ActionIcon'; -import { createExploreLink } from '../../utils/misc'; -import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; -import { useDispatch } from 'react-redux'; -import { deleteRuleAction } from '../../state/actions'; +import { isCloudRulesSource } from '../../utils/datasource'; import { useHasRuler } from '../../hooks/useHasRuler'; import { CombinedRule } from 'app/types/unified-alerting'; import { AlertStateTag } from './AlertStateTag'; @@ -20,16 +16,16 @@ interface Props { showGuidelines?: boolean; showGroupColumn?: boolean; emptyMessage?: string; + className?: string; } export const RulesTable: FC = ({ rules, + className, showGuidelines = false, emptyMessage = 'No rules found.', showGroupColumn = false, }) => { - const dispatch = useDispatch(); - const hasRuler = useHasRuler(); const styles = useStyles2(getStyles); @@ -37,30 +33,12 @@ export const RulesTable: FC = ({ const [expandedKeys, setExpandedKeys] = useState([]); - const [ruleToDelete, setRuleToDelete] = useState(); - const toggleExpandedState = (ruleKey: string) => setExpandedKeys( expandedKeys.includes(ruleKey) ? expandedKeys.filter((key) => key !== ruleKey) : [...expandedKeys, ruleKey] ); - const deleteRule = () => { - if (ruleToDelete && ruleToDelete.rulerRule) { - dispatch( - deleteRuleAction( - getRuleIdentifier( - getRulesSourceName(ruleToDelete.namespace.rulesSource), - ruleToDelete.namespace.name, - ruleToDelete.group.name, - ruleToDelete.rulerRule - ) - ) - ); - setRuleToDelete(undefined); - } - }; - - const wrapperClass = cx(styles.wrapper, { [styles.wrapperMargin]: showGuidelines }); + const wrapperClass = cx(styles.wrapper, className, { [styles.wrapperMargin]: showGuidelines }); if (!rules.length) { return
{emptyMessage}
; @@ -75,7 +53,6 @@ export const RulesTable: FC = ({ - {showGroupColumn && } @@ -87,7 +64,6 @@ export const RulesTable: FC = ({ Name {showGroupColumn && Group} Status - Actions @@ -140,30 +116,6 @@ export const RulesTable: FC = ({ {isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name} )} {statuses.join(', ') || 'n/a'} - - {isCloudRulesSource(rulesSource) && ( - - )} - {!!rulerRule && ( - - )} - {!!rulerRule && ( - setRuleToDelete(rule)} /> - )} - {isExpanded && ( @@ -172,7 +124,7 @@ export const RulesTable: FC = ({
)} - + @@ -183,17 +135,6 @@ export const RulesTable: FC = ({ })()} - {!!ruleToDelete && ( - setRuleToDelete(undefined)} - /> - )}
); }; @@ -206,7 +147,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(1)}; `, wrapper: css` - margin-top: ${theme.spacing(3)}; width: auto; background-color: ${theme.colors.background.secondary}; border-radius: ${theme.shape.borderRadius()}; diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 7d965b9ff77..dead309aef0 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -9,7 +9,12 @@ import { } from 'app/types/unified-alerting'; import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { useMemo, useRef } from 'react'; -import { getAllRulesSources, isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource'; +import { + getAllRulesSources, + getRulesSourceByName, + isCloudRulesSource, + isGrafanaRulesSource, +} from '../utils/datasource'; import { isAlertingRule, isAlertingRulerRule, isRecordingRulerRule } from '../utils/rules'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; @@ -20,16 +25,28 @@ interface CacheValue { } // this little monster combines prometheus rules and ruler rules to produce a unfied data structure -export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] { +// can limit to a single rules source +export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRuleNamespace[] { const promRulesResponses = useUnifiedAlertingSelector((state) => state.promRules); const rulerRulesResponses = useUnifiedAlertingSelector((state) => state.rulerRules); // cache results per rules source, so we only recalculate those for which results have actually changed const cache = useRef>({}); + const rulesSources = useMemo((): RulesSource[] => { + if (rulesSourceName) { + const rulesSource = getRulesSourceByName(rulesSourceName); + if (!rulesSource) { + throw new Error(`Unknown rules source: ${rulesSourceName}`); + } + return [rulesSource]; + } + return getAllRulesSources(); + }, [rulesSourceName]); + return useMemo( () => - getAllRulesSources() + rulesSources .map((rulesSource): CombinedRuleNamespace[] => { const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource; const promRules = promRulesResponses[rulesSourceName]?.result; @@ -79,7 +96,7 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] { return result; }) .flat(), - [promRulesResponses, rulerRulesResponses] + [promRulesResponses, rulerRulesResponses, rulesSources] ); } diff --git a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts new file mode 100644 index 00000000000..85bd7666eb3 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts @@ -0,0 +1,76 @@ +import { SerializedError } from '@reduxjs/toolkit'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { useDispatch } from 'react-redux'; +import { fetchPromRulesAction, fetchRulerRulesAction } from '../state/actions'; +import { Annotation, RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; +import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { initialAsyncRequestState } from '../utils/redux'; +import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; +import { useEffect, useMemo } from 'react'; +interface Options { + dashboard: DashboardModel; + panel: PanelModel; + + poll?: boolean; +} + +interface ReturnBag { + errors: SerializedError[]; + rules: CombinedRule[]; + + loading?: boolean; +} + +export function usePanelCombinedRules({ dashboard, panel, poll = false }: Options): ReturnBag { + const dispatch = useDispatch(); + + const promRuleRequest = + useUnifiedAlertingSelector((state) => state.promRules[GRAFANA_RULES_SOURCE_NAME]) ?? initialAsyncRequestState; + const rulerRuleRequest = + useUnifiedAlertingSelector((state) => state.rulerRules[GRAFANA_RULES_SOURCE_NAME]) ?? initialAsyncRequestState; + + // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS + useEffect(() => { + const fetch = () => { + dispatch(fetchPromRulesAction(GRAFANA_RULES_SOURCE_NAME)); + dispatch(fetchRulerRulesAction(GRAFANA_RULES_SOURCE_NAME)); + }; + fetch(); + if (poll) { + const interval = setInterval(fetch, RULE_LIST_POLL_INTERVAL_MS); + return () => { + clearInterval(interval); + }; + } + return () => {}; + }, [dispatch, poll]); + + const loading = promRuleRequest.loading || rulerRuleRequest.loading; + const errors = [promRuleRequest.error, rulerRuleRequest.error].filter( + (err: SerializedError | undefined): err is SerializedError => !!err + ); + + const combinedNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); + + // filter out rules that are relevant to this panel + const rules = useMemo( + (): CombinedRule[] => + combinedNamespaces + .flatMap((ns) => ns.groups) + .flatMap((group) => group.rules) + .filter( + (rule) => + rule.annotations[Annotation.dashboardUID] === dashboard.uid && + rule.annotations[Annotation.panelID] === String(panel.editSourceId) + ), + [combinedNamespaces, dashboard, panel] + ); + + return { + rules, + errors, + loading, + }; +} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 03286a6b405..fdd93d76d41 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -291,11 +291,11 @@ export const saveRuleFormAction = createAsyncThunk( ({ values, existing, - exitOnSave, + redirectOnSave, }: { values: RuleFormValues; existing?: RuleWithLocation; - exitOnSave: boolean; + redirectOnSave?: string; }): Promise => withSerializedError( (async () => { @@ -310,8 +310,8 @@ export const saveRuleFormAction = createAsyncThunk( } else { throw new Error('Unexpected rule form type'); } - if (exitOnSave) { - locationService.push('/alerting/list'); + if (redirectOnSave) { + locationService.push(redirectOnSave); } else { // redirect to edit page const newLocation = `/alerting/${encodeURIComponent(stringifyRuleIdentifier(identifier))}/edit`; diff --git a/public/app/features/alerting/unified/utils/constants.ts b/public/app/features/alerting/unified/utils/constants.ts index 1fc2b6145be..22a3ceb9140 100644 --- a/public/app/features/alerting/unified/utils/constants.ts +++ b/public/app/features/alerting/unified/utils/constants.ts @@ -5,3 +5,21 @@ export const RULE_LIST_POLL_INTERVAL_MS = 20000; export const ALERTMANAGER_NAME_QUERY_KEY = 'alertmanager'; export const ALERTMANAGER_NAME_LOCAL_STORAGE_KEY = 'alerting-alertmanager'; export const SILENCES_POLL_INTERVAL_MS = 20000; + +export enum Annotation { + description = 'description', + summary = 'summary', + runbookURL = 'runbook_url', + alertId = '__alertId__', + dashboardUID = '__dashboardUid__', + panelID = '__panelId__', +} + +export const annotationLabels: Record = { + [Annotation.description]: 'Description', + [Annotation.summary]: 'Summary', + [Annotation.runbookURL]: 'Runbook URL', + [Annotation.dashboardUID]: 'Dashboard UID', + [Annotation.panelID]: 'Panel ID', + [Annotation.alertId]: 'Alert ID', +}; diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index ab09675984a..fe81d38aba8 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -61,6 +61,15 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings source.name === name); } +export function getRulesSourceByName( + name: string +): DataSourceInstanceSettings | typeof GRAFANA_RULES_SOURCE_NAME | undefined { + if (name === GRAFANA_RULES_SOURCE_NAME) { + return GRAFANA_RULES_SOURCE_NAME; + } + return getDataSourceByName(name); +} + export function getDatasourceAPIId(dataSourceName: string) { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { return GRAFANA_RULES_SOURCE_NAME; diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index eb92e9d5ae1..1780df007cd 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -1,7 +1,10 @@ -import { getDefaultTimeRange, rangeUtil } from '@grafana/data'; +import { DataQuery, getDefaultTimeRange, rangeUtil, RelativeTimeRange } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; +import { getNextRefIdChar } from 'app/core/utils/query'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { ExpressionDatasourceID, ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource'; import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { Annotations, @@ -13,6 +16,7 @@ import { } from 'app/types/unified-alerting-dto'; import { EvalFunction } from '../../state/alertDef'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; +import { Annotation } from './constants'; import { isGrafanaRulesSource } from './datasource'; import { arrayToRecord, recordToArray } from './misc'; import { isAlertingRulerRule, isGrafanaRulerRule } from './rules'; @@ -180,3 +184,93 @@ const getDefaultExpression = (refId: string): GrafanaQuery => { model, }; }; + +const dataQueriesToGrafanaQueries = ( + queries: DataQuery[], + relativeTimeRange: RelativeTimeRange, + datasourceName?: string +): GrafanaQuery[] => { + return queries.reduce((queries, target) => { + const dsName = target.datasource || datasourceName; + if (dsName) { + // expressions + if (dsName === ExpressionDatasourceID) { + const newQuery: GrafanaQuery = { + refId: target.refId, + queryType: '', + relativeTimeRange, + datasourceUid: ExpressionDatasourceUID, + model: target, + }; + return [...queries, newQuery]; + // queries + } else { + const datasource = getDataSourceSrv().getInstanceSettings(target.datasource || datasourceName); + if (datasource && datasource.meta.alerting) { + const newQuery: GrafanaQuery = { + refId: target.refId, + queryType: target.queryType ?? '', + relativeTimeRange, + datasourceUid: datasource.uid, + model: target, + }; + return [...queries, newQuery]; + } + } + } + return queries; + }, []); +}; + +export const panelToRuleFormValues = ( + panel: PanelModel, + dashboard: DashboardModel +): Partial | undefined => { + const { targets } = panel; + + // it seems if default datasource is selected, datasource=null, hah + const datasourceName = + panel.datasource === null ? getDatasourceSrv().getInstanceSettings('default')?.name : panel.datasource; + + if (!panel.editSourceId || !dashboard.uid) { + return undefined; + } + + const relativeTimeRange = rangeUtil.timeRangeToRelative(rangeUtil.convertRawToRange(dashboard.time)); + const queries = dataQueriesToGrafanaQueries(targets, relativeTimeRange, datasourceName); + + // if no alerting capable queries are found, can't create a rule + if (!queries.length || !queries.find((query) => query.datasourceUid !== ExpressionDatasourceUID)) { + return undefined; + } + + if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) { + queries.push(getDefaultExpression(getNextRefIdChar(queries.map((query) => query.model)))); + } + + const { folderId, folderTitle } = dashboard.meta; + + const formValues = { + type: RuleFormType.threshold, + folder: + folderId && folderTitle + ? { + id: folderId, + title: folderTitle, + } + : undefined, + queries, + name: panel.title, + annotations: [ + { + key: Annotation.dashboardUID, + value: dashboard.uid, + }, + { + key: Annotation.panelID, + value: String(panel.editSourceId), + }, + ], + }; + return formValues; +}; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx index 56a6e2234da..d2424ad2860 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx @@ -1,7 +1,6 @@ import React, { FC, useEffect } from 'react'; import { css } from '@emotion/css'; import { IconName, Tab, TabContent, TabsBar, useForceUpdate, useStyles2 } from '@grafana/ui'; -import { AlertTab } from 'app/features/alerting/AlertTab'; import { TransformationsEditor } from '../TransformationsEditor/TransformationsEditor'; import { DashboardModel, PanelModel } from '../../state'; import { PanelEditorTab, PanelEditorTabId } from './types'; @@ -9,6 +8,9 @@ import { Subscription } from 'rxjs'; import { PanelQueriesChangedEvent, PanelTransformationsChangedEvent } from 'app/types/events'; import { PanelEditorQueries } from './PanelEditorQueries'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import AlertTabIndex from 'app/features/alerting/AlertTabIndex'; +import { PanelAlertTab } from 'app/features/alerting/unified/PanelAlertTab'; interface PanelEditorTabsProps { panel: PanelModel; @@ -38,6 +40,19 @@ export const PanelEditorTabs: FC = React.memo(({ panel, da
{tabs.map((tab) => { + if (config.featureToggles.ngalert && tab.id === PanelEditorTabId.Alert) { + return ( + onChangeTab(tab)} + icon={tab.icon as IconName} + panel={panel} + dashboard={dashboard} + /> + ); + } return ( = React.memo(({ panel, da {activeTab.id === PanelEditorTabId.Query && } - {activeTab.id === PanelEditorTabId.Alert && } + {activeTab.id === PanelEditorTabId.Alert && } {activeTab.id === PanelEditorTabId.Transform && }
diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 5ac44c25480..d5e01503f6d 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -385,7 +385,7 @@ export class PanelChrome extends Component { const { errorMessage, data } = this.state; const { transparent } = panel; - let alertState = data.alertState?.state; + let alertState = config.featureToggles.ngalert ? undefined : data.alertState?.state; const containerClassNames = classNames({ 'panel-container': true, diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx index 08bac0b99d9..6160798bc04 100644 --- a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -193,7 +193,7 @@ export class PanelChromeAngularUnconnected extends PureComponent { const { errorMessage, data } = this.state; const { transparent } = panel; - let alertState = data.alertState?.state; + let alertState = config.featureToggles.ngalert ? undefined : data.alertState?.state; const containerClassNames = classNames({ 'panel-container': true, diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index b4c5bb2f019..dbaee0433a8 100644 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -63,7 +63,7 @@ class GraphElement { data: any[] = []; panelWidth: number; eventManager: EventManager; - thresholdManager: ThresholdManager; + thresholdManager?: ThresholdManager; timeRegionManager: TimeRegionManager; legendElem: HTMLElement; @@ -76,7 +76,10 @@ class GraphElement { this.panelWidth = 0; this.eventManager = new EventManager(this.ctrl); - this.thresholdManager = new ThresholdManager(this.ctrl); + // unified alerting does not support threshold for graphs, at least for now + if (!config.featureToggles.ngalert) { + this.thresholdManager = new ThresholdManager(this.ctrl); + } this.timeRegionManager = new TimeRegionManager(this.ctrl); // @ts-ignore this.tooltip = new GraphTooltip(this.elem, this.ctrl.dashboard, this.scope, () => { @@ -379,8 +382,9 @@ class GraphElement { } msg.appendTo(this.elem); } - - this.thresholdManager.draw(plot); + if (this.thresholdManager) { + this.thresholdManager.draw(plot); + } this.timeRegionManager.draw(plot); } @@ -451,7 +455,9 @@ class GraphElement { } // give space to alert editing - this.thresholdManager.prepare(this.elem, this.data); + if (this.thresholdManager) { + this.thresholdManager.prepare(this.elem, this.data); + } // un-check dashes if lines are unchecked this.panel.dashes = this.panel.lines ? this.panel.dashes : false; @@ -460,7 +466,9 @@ class GraphElement { const options: any = this.buildFlotOptions(this.panel); this.prepareXAxis(options, this.panel); this.configureYAxisOptions(this.data, options); - this.thresholdManager.addFlotOptions(options, this.panel); + if (this.thresholdManager) { + this.thresholdManager.addFlotOptions(options, this.panel); + } this.timeRegionManager.addFlotOptions(options, this.panel); this.eventManager.addFlotEvents(this.annotations, options); this.sortedSeries = this.sortSeries(this.data, this.panel);