mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: update panel alert tab (#33850)
This commit is contained in:
7
public/app/features/alerting/AlertTabIndex.tsx
Normal file
7
public/app/features/alerting/AlertTabIndex.tsx
Normal 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;
|
||||
15
public/app/features/alerting/unified/PanelAlertTab.tsx
Normal file
15
public/app/features/alerting/unified/PanelAlertTab.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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};
|
||||
`,
|
||||
});
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -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()};
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user