Alerting: update panel alert tab (#33850)

This commit is contained in:
Domas
2021-05-17 10:39:42 +03:00
committed by GitHub
parent 7c9ac0f990
commit a26507e9c4
28 changed files with 670 additions and 146 deletions

View File

@@ -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;

View File

@@ -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<TabProps, 'counter' | 'ref'> {
panel: PanelModel;
dashboard: DashboardModel;
}
// it will load rule count from backend
export const PanelAlertTab: FC<Props> = ({ panel, dashboard, ...otherProps }) => {
const { rules, loading } = usePanelCombinedRules({ panel, dashboard });
return <Tab {...otherProps} counter={loading ? null : rules.length} />;
};

View File

@@ -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<Props> = ({ dashboard, panel }) => {
const styles = useStyles2(getStyles);
const { errors, loading, rules } = usePanelCombinedRules({
dashboard,
panel,
poll: true,
});
const alert = errors.length ? (
<Alert title="Errors loading rules" severity="error">
{errors.map((error, index) => (
<div key={index}>Failed to load Grafana threshold rules state: {error.message || 'Unknown error.'}</div>
))}
</Alert>
) : null;
if (loading && !rules.length) {
return (
<div className={styles.innerWrapper}>
{alert}
<LoadingPlaceholder text="Loading rules..." />
</div>
);
}
if (rules.length) {
return (
<CustomScrollbar autoHeightMin="100%">
<div className={styles.innerWrapper}>
{alert}
<RulesTable rules={rules} />
<NewRuleFromPanelButton className={styles.newButton} panel={panel} dashboard={dashboard} />
</div>
</CustomScrollbar>
);
}
return (
<div className={styles.noRulesWrapper}>
{alert}
{!!dashboard.uid ? (
<>
<p>There are no alert rules linked to this panel.</p>
<NewRuleFromPanelButton panel={panel} dashboard={dashboard} />
</>
) : (
<Alert severity="info" title="Dashboard not saved">
Dashboard must be saved before alerts can be added.
</Alert>
)}
</div>
);
};
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)};
`,
});

View File

@@ -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 = () => {
</a>
</ButtonGroup>
<div />
<a href={'alerting/new'}>
<Button icon="plus">New alert rule</Button>
</a>
<LinkButton
href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })}
icon="plus"
>
New alert rule
</LinkButton>
</div>
</>
)}

View File

@@ -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<Props> = ({ annotationKey, value }) => {
export const AnnotationDetailsField: FC<Props> = ({ annotationKey, value }) => {
const label = annotationLabels[annotationKey as Annotation] ? (
<Tooltip content={annotationKey} placement="top" theme="info">
<span>{annotationLabels[annotationKey as Annotation]}</span>
</Tooltip>
) : (
annotationKey
);
return (
<DetailsField label={label} horizontal={true}>
<AnnotationValue annotationKey={annotationKey} value={value} />
</DetailsField>
);
};
const AnnotationValue: FC<Props> = ({ annotationKey, value }) => {
const styles = useStyles(getStyles);
if (wellableAnnotationKeys.includes(annotationKey)) {
return <Well>{value}</Well>;

View File

@@ -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;

View File

@@ -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<Props> = ({ dashboard, panel, className }) => {
const formValues = panelToRuleFormValues(panel, dashboard);
const location = useLocation();
if (!formValues) {
return (
<Alert severity="info" title="No alerting capable query found">
Cannot create alerts from this panel because no query to an alerting capable datasource is found.
</Alert>
);
}
const ruleFormUrl = urlUtil.renderUrl('alerting/new', {
defaults: JSON.stringify(formValues),
returnTo: location.pathname + location.search,
});
return (
<LinkButton icon="bell" href={ruleFormUrl} className={className}>
Create alert rule from this panel
</LinkButton>
);
};

View File

@@ -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<Props> = ({ 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<Props> = ({ existing }) => {
return {
...defaultFormValues,
queries: getDefaultQueries(),
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
};
}, [existing]);
}, [existing, queryParams]);
const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit',
@@ -68,7 +73,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
labels: values.labels?.filter(({ key }) => !!key) ?? [],
},
existing,
exitOnSave,
redirectOnSave: exitOnSave ? returnTo : undefined,
})
);
};
@@ -77,7 +82,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit((values) => submit(values, false))} className={styles.form}>
<PageToolbar title="Create alert rule" pageIcon="bell">
<Link to="/alerting/list">
<Link to={returnTo}>
<Button variant="secondary" disabled={submitState.loading} type="button" fill="outline">
Cancel
</Button>

View File

@@ -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<Props> = ({ 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<Props> = ({ value, existingKeys, ...rest })
<SelectWithAdd
value={value}
options={annotationOptions}
custom={!!value && !Object.keys(AnnotationOptions).includes(value)}
custom={!!value && !(Object.values(Annotation) as string[]).includes(value)}
{...rest}
/>
);

View File

@@ -29,7 +29,7 @@ const AnnotationsField: FC = () => {
return (
<div className={styles.flexColumn}>
{fields.map((field, index) => (
<div key={`${field.annotationKey}-${index}`} className={styles.flexRow}>
<div key={field.id} className={styles.flexRow}>
<Field
className={styles.field}
invalid={!!errors.annotations?.[index]?.key?.message}
@@ -38,7 +38,7 @@ const AnnotationsField: FC = () => {
<InputControl
name={`annotations[${index}].key`}
render={({ field: { ref, ...field } }) => (
<AnnotationKeyInput {...field} existingKeys={existingKeys(index)} width={15} />
<AnnotationKeyInput {...field} existingKeys={existingKeys(index)} width={18} />
)}
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`

View File

@@ -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 (
<Field

View File

@@ -25,7 +25,7 @@ const LabelsField: FC<Props> = ({ className }) => {
return (
<>
<div className={styles.flexRow}>
<InlineLabel width={15}>Labels</InlineLabel>
<InlineLabel width={18}>Labels</InlineLabel>
<div className={styles.flexColumn}>
{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};

View File

@@ -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<Props> = ({ instance }) => {
</DetailsField>
)}
{annotations.map(([key, value]) => (
<DetailsField key={key} label={key} horizontal={true}>
<Annotation annotationKey={key} value={value} />
</DetailsField>
<AnnotationDetailsField key={key} annotationKey={key} value={value} />
))}
</div>
);

View File

@@ -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<Props> = ({ rule, rulesSource }) => {
return (
<div>
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
<div className={styles.wrapper}>
<div className={styles.leftSide}>
{!!rule.labels && !!Object.keys(rule.labels).length && (
@@ -67,9 +69,7 @@ export const RuleDetails: FC<Props> = ({ rule, rulesSource }) => {
</DetailsField>
)}
{annotations.map(([key, value]) => (
<DetailsField key={key} label={key} horizontal={true}>
<Annotation annotationKey={key} value={value} />
</DetailsField>
<AnnotationDetailsField key={key} annotationKey={key} value={value} />
))}
</div>
<div className={styles.rightSide}>

View File

@@ -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<Props> = ({ rule, rulesSource }) => {
const dispatch = useDispatch();
const location = useLocation();
const style = useStyles2(getStyles);
const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
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(
<LinkButton
className={style.button}
size="xs"
key="explore"
variant="primary"
icon="chart-line"
target="__blank"
href={createExploreLink(rulesSource.name, rule.query)}
>
See graph
</LinkButton>
);
}
if (rule.annotations[Annotation.runbookURL]) {
leftButtons.push(
<LinkButton
className={style.button}
size="xs"
key="runbook"
variant="primary"
icon="book"
target="__blank"
href={rule.annotations[Annotation.runbookURL]}
>
View runbook
</LinkButton>
);
}
if (rule.annotations[Annotation.dashboardUID]) {
const dashboardUID = rule.annotations[Annotation.dashboardUID];
if (dashboardUID) {
leftButtons.push(
<LinkButton
className={style.button}
size="xs"
key="dashboard"
variant="primary"
icon="apps"
target="__blank"
href={`d/${encodeURIComponent(dashboardUID)}`}
>
Go to dashboard
</LinkButton>
);
const panelId = rule.annotations[Annotation.panelID];
if (panelId) {
leftButtons.push(
<LinkButton
className={style.button}
size="xs"
key="dashboard"
variant="primary"
icon="apps"
target="__blank"
href={`d/${encodeURIComponent(dashboardUID)}?viewPanel=${encodeURIComponent(panelId)}`}
>
Go to panel
</LinkButton>
);
}
}
}
// @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(
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>,
<Button
className={style.button}
size="xs"
type="button"
key="delete"
variant="secondary"
icon="trash-alt"
onClick={() => setRuleToDelete(rule)}
>
Delete
</Button>
);
}
if (leftButtons.length || rightButtons.length) {
return (
<>
<div className={style.wrapper}>
<HorizontalGroup width="auto">{leftButtons.length ? leftButtons : <div />}</HorizontalGroup>
<HorizontalGroup width="auto">{rightButtons.length ? rightButtons : <div />}</HorizontalGroup>
</div>
{!!ruleToDelete && (
<ConfirmModal
isOpen={true}
title="Delete rule"
body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
confirmText="Yes, delete"
icon="exclamation-triangle"
onConfirm={deleteRule}
onDismiss={() => 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};
`,
});

View File

@@ -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<Props> = ({ rules, state, defaultCollapsed = false }) => {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const styles = useStyles(getStyles);
const styles = useStyles2(getStyles);
return (
<>
<h4 className={styles.header}>
@@ -28,16 +28,19 @@ export const RuleListStateSection: FC<Props> = ({ rules, state, defaultCollapsed
/>
{alertStateToReadable(state)} ({rules.length})
</h4>
{!collapsed && <RulesTable rules={rules} showGroupColumn={true} />}
{!collapsed && <RulesTable className={styles.rulesTable} rules={rules} showGroupColumn={true} />}
</>
);
};
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)};
`,
});

View File

@@ -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<Props> = 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<Props> = React.memo(({ group, namespace }) => {
</>
)}
</div>
{!isCollapsed && <RulesTable showGuidelines={true} rules={group.rules} />}
{!isCollapsed && <RulesTable className={styles.rulesTable} showGuidelines={true} rules={group.rules} />}
</div>
);
});
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)};
`,
});

View File

@@ -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<Props> = ({
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<Props> = ({
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
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 <div className={cx(wrapperClass, styles.emptyMessage)}>{emptyMessage}</div>;
@@ -75,7 +53,6 @@ export const RulesTable: FC<Props> = ({
<col />
<col />
<col />
<col />
{showGroupColumn && <col />}
</colgroup>
<thead>
@@ -87,7 +64,6 @@ export const RulesTable: FC<Props> = ({
<th>Name</th>
{showGroupColumn && <th>Group</th>}
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -140,30 +116,6 @@ export const RulesTable: FC<Props> = ({
<td>{isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name}</td>
)}
<td>{statuses.join(', ') || 'n/a'}</td>
<td className={tableStyles.actionsCell}>
{isCloudRulesSource(rulesSource) && (
<ActionIcon
icon="chart-line"
tooltip="view in explore"
target="__blank"
to={createExploreLink(rulesSource.name, rule.query)}
/>
)}
{!!rulerRule && (
<ActionIcon
icon="pen"
tooltip="edit rule"
to={`/alerting/${encodeURIComponent(
stringifyRuleIdentifier(
getRuleIdentifier(getRulesSourceName(rulesSource), namespace.name, group.name, rulerRule)
)
)}/edit`}
/>
)}
{!!rulerRule && (
<ActionIcon icon="trash-alt" tooltip="delete rule" onClick={() => setRuleToDelete(rule)} />
)}
</td>
</tr>
{isExpanded && (
<tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}>
@@ -172,7 +124,7 @@ export const RulesTable: FC<Props> = ({
<div className={cx(styles.ruleContentGuideline, styles.guideline)} />
)}
</td>
<td colSpan={showGroupColumn ? 5 : 4}>
<td colSpan={showGroupColumn ? 4 : 3}>
<RuleDetails rulesSource={rulesSource} rule={rule} />
</td>
</tr>
@@ -183,17 +135,6 @@ export const RulesTable: FC<Props> = ({
})()}
</tbody>
</table>
{!!ruleToDelete && (
<ConfirmModal
isOpen={true}
title="Delete rule"
body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
confirmText="Yes, delete"
icon="exclamation-triangle"
onConfirm={deleteRule}
onDismiss={() => setRuleToDelete(undefined)}
/>
)}
</div>
);
};
@@ -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()};

View File

@@ -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<Record<string, CacheValue>>({});
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]
);
}

View File

@@ -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,
};
}

View File

@@ -291,11 +291,11 @@ export const saveRuleFormAction = createAsyncThunk(
({
values,
existing,
exitOnSave,
redirectOnSave,
}: {
values: RuleFormValues;
existing?: RuleWithLocation;
exitOnSave: boolean;
redirectOnSave?: string;
}): Promise<void> =>
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`;

View File

@@ -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, string> = {
[Annotation.description]: 'Description',
[Annotation.summary]: 'Summary',
[Annotation.runbookURL]: 'Runbook URL',
[Annotation.dashboardUID]: 'Dashboard UID',
[Annotation.panelID]: 'Panel ID',
[Annotation.alertId]: 'Alert ID',
};

View File

@@ -61,6 +61,15 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings<Da
return getAllDataSources().find((source) => source.name === name);
}
export function getRulesSourceByName(
name: string
): DataSourceInstanceSettings<DataSourceJsonData> | 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;

View File

@@ -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<GrafanaQuery[]>((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<RuleFormValues> | 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;
};

View File

@@ -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<PanelEditorTabsProps> = React.memo(({ panel, da
<div className={styles.wrapper}>
<TabsBar className={styles.tabBar}>
{tabs.map((tab) => {
if (config.featureToggles.ngalert && tab.id === PanelEditorTabId.Alert) {
return (
<PanelAlertTab
key={tab.id}
label={tab.text}
active={tab.active}
onChangeTab={() => onChangeTab(tab)}
icon={tab.icon as IconName}
panel={panel}
dashboard={dashboard}
/>
);
}
return (
<Tab
key={tab.id}
@@ -52,7 +67,7 @@ export const PanelEditorTabs: FC<PanelEditorTabsProps> = React.memo(({ panel, da
</TabsBar>
<TabContent className={styles.tabContent}>
{activeTab.id === PanelEditorTabId.Query && <PanelEditorQueries panel={panel} queries={panel.targets} />}
{activeTab.id === PanelEditorTabId.Alert && <AlertTab panel={panel} dashboard={dashboard} />}
{activeTab.id === PanelEditorTabId.Alert && <AlertTabIndex panel={panel} dashboard={dashboard} />}
{activeTab.id === PanelEditorTabId.Transform && <TransformationsEditor panel={panel} />}
</TabContent>
</div>

View File

@@ -385,7 +385,7 @@ export class PanelChrome extends Component<Props, State> {
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,

View File

@@ -193,7 +193,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
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,

View File

@@ -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);