mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Rule edit form (#32877)
This commit is contained in:
parent
727a24c1bb
commit
282c62d8bf
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,6 +16,7 @@ awsconfig
|
||||
.yarn/
|
||||
vendor/
|
||||
/docs/menu.yaml
|
||||
/requests
|
||||
|
||||
# Enterprise emails
|
||||
/emails/templates/enterprise_*
|
||||
|
@ -26,9 +26,11 @@ export interface DataSourcePickerProps {
|
||||
metrics?: boolean;
|
||||
annotations?: boolean;
|
||||
variables?: boolean;
|
||||
alerting?: boolean;
|
||||
pluginId?: string;
|
||||
noDefault?: boolean;
|
||||
width?: number;
|
||||
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -106,7 +108,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
|
||||
}
|
||||
|
||||
getDataSourceOptions() {
|
||||
const { tracing, metrics, mixed, dashboard, variables, annotations, pluginId } = this.props;
|
||||
const { tracing, metrics, mixed, dashboard, variables, annotations, pluginId, alerting, filter } = this.props;
|
||||
const options = this.dataSourceSrv
|
||||
.getList({
|
||||
tracing,
|
||||
@ -116,6 +118,8 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
|
||||
variables,
|
||||
annotations,
|
||||
pluginId,
|
||||
alerting,
|
||||
filter,
|
||||
})
|
||||
.map((ds) => ({
|
||||
value: ds.name,
|
||||
@ -149,7 +153,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
|
||||
maxMenuHeight={500}
|
||||
placeholder={placeholder}
|
||||
noOptionsMessage="No datasources found"
|
||||
value={value}
|
||||
value={value ?? null}
|
||||
invalid={!!error}
|
||||
getOptionLabel={(o) => {
|
||||
if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) {
|
||||
|
@ -40,6 +40,9 @@ export interface GetDataSourceListFilters {
|
||||
/** Only return data sources that support annotations */
|
||||
annotations?: boolean;
|
||||
|
||||
/** Only filter data sources that support alerting */
|
||||
alerting?: boolean;
|
||||
|
||||
/**
|
||||
* By default only data sources that can be queried will be returned. Meaning they have tracing,
|
||||
* metrics, logs or annotations flag set in plugin.json file
|
||||
@ -54,6 +57,9 @@ export interface GetDataSourceListFilters {
|
||||
|
||||
/** filter list by plugin */
|
||||
pluginId?: string;
|
||||
|
||||
/** apply a function to filter */
|
||||
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
|
||||
}
|
||||
|
||||
let singletonInstance: DataSourceSrv;
|
||||
|
@ -14,7 +14,7 @@ export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
|
||||
|
||||
interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
|
||||
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
||||
value?: SelectableValue<T>;
|
||||
value?: SelectableValue<T> | null;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
|
@ -19,10 +19,12 @@ export interface Props {
|
||||
initialTitle?: string;
|
||||
initialFolderId?: number;
|
||||
permissionLevel?: 'View' | 'Edit';
|
||||
allowEmpty?: boolean;
|
||||
showRoot?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
folder: SelectableValue<number>;
|
||||
folder: SelectableValue<number> | null;
|
||||
}
|
||||
|
||||
export class FolderPicker extends PureComponent<Props, State> {
|
||||
@ -32,7 +34,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
folder: {},
|
||||
folder: null,
|
||||
};
|
||||
|
||||
this.debouncedSearch = debounce(this.getOptions, 300, {
|
||||
@ -47,6 +49,8 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
initialTitle: '',
|
||||
enableCreateNew: false,
|
||||
permissionLevel: 'Edit',
|
||||
allowEmpty: false,
|
||||
showRoot: true,
|
||||
};
|
||||
|
||||
componentDidMount = async () => {
|
||||
@ -54,7 +58,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
getOptions = async (query: string) => {
|
||||
const { rootName, enableReset, initialTitle, permissionLevel } = this.props;
|
||||
const { rootName, enableReset, initialTitle, permissionLevel, showRoot } = this.props;
|
||||
const params = {
|
||||
query,
|
||||
type: 'dash-folder',
|
||||
@ -64,8 +68,9 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
// TODO: move search to BackendSrv interface
|
||||
// @ts-ignore
|
||||
const searchHits = (await getBackendSrv().search(params)) as DashboardSearchHit[];
|
||||
|
||||
const options: Array<SelectableValue<number>> = searchHits.map((hit) => ({ label: hit.title, value: hit.id }));
|
||||
if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase())) {
|
||||
if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) {
|
||||
options.unshift({ label: rootName, value: 0 });
|
||||
}
|
||||
|
||||
@ -111,15 +116,15 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
|
||||
const options = await this.getOptions('');
|
||||
|
||||
let folder: SelectableValue<number> = { value: -1 };
|
||||
let folder: SelectableValue<number> | null = null;
|
||||
|
||||
if (initialFolderId !== undefined && initialFolderId !== null && initialFolderId > -1) {
|
||||
folder = options.find((option) => option.value === initialFolderId) || { value: -1 };
|
||||
folder = options.find((option) => option.value === initialFolderId) || null;
|
||||
} else if (enableReset && initialTitle) {
|
||||
folder = resetFolder;
|
||||
}
|
||||
|
||||
if (folder.value === -1) {
|
||||
if (!folder && !this.props.allowEmpty) {
|
||||
if (contextSrv.isEditor) {
|
||||
folder = rootFolder;
|
||||
} else {
|
||||
@ -139,8 +144,8 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
},
|
||||
() => {
|
||||
// if this is not the same as our initial value notify parent
|
||||
if (folder.value !== initialFolderId) {
|
||||
this.props.onChange({ id: folder.value!, title: folder.text });
|
||||
if (folder && folder.value !== initialFolderId) {
|
||||
this.props.onChange({ id: folder.value!, title: folder.label! });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -7,12 +7,12 @@ exports[`FolderPicker should render 1`] = `
|
||||
<AsyncSelect
|
||||
allowCustomValue={false}
|
||||
defaultOptions={true}
|
||||
defaultValue={Object {}}
|
||||
defaultValue={null}
|
||||
loadOptions={[Function]}
|
||||
loadingMessage="Loading folders..."
|
||||
onChange={[Function]}
|
||||
onCreateOption={[Function]}
|
||||
value={Object {}}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
0
public/app/core/hooks/useAsyncAction.ts
Normal file
0
public/app/core/hooks/useAsyncAction.ts
Normal file
6
public/app/features/alerting/unified/RuleEditor.tsx
Normal file
6
public/app/features/alerting/unified/RuleEditor.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import React, { FC } from 'react';
|
||||
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
||||
|
||||
const RuleEditor: FC = () => <AlertRuleForm />;
|
||||
|
||||
export default RuleEditor;
|
@ -113,7 +113,7 @@ describe('RuleList', () => {
|
||||
} else if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) {
|
||||
return Promise.resolve([
|
||||
mockPromRuleNamespace({
|
||||
name: '',
|
||||
name: 'foofolder',
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
mockPromRuleGroup({
|
||||
@ -132,7 +132,7 @@ describe('RuleList', () => {
|
||||
const groups = await ui.ruleGroup.findAll();
|
||||
expect(groups).toHaveLength(5);
|
||||
|
||||
expect(groups[0]).toHaveTextContent('grafana-group');
|
||||
expect(groups[0]).toHaveTextContent('foofolder');
|
||||
expect(groups[1]).toHaveTextContent('default > group-1');
|
||||
expect(groups[2]).toHaveTextContent('default > group-1');
|
||||
expect(groups[3]).toHaveTextContent('default > group-2');
|
||||
|
@ -7,7 +7,7 @@ import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { NoRulesSplash } from './components/rules/NoRulesCTA';
|
||||
import { SystemOrApplicationRules } from './components/rules/SystemOrApplicationRules';
|
||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||
import { fetchAllPromAndRulerRules } from './state/actions';
|
||||
import { fetchAllPromAndRulerRulesAction } from './state/actions';
|
||||
import {
|
||||
getAllRulesSourceNames,
|
||||
getRulesDataSources,
|
||||
@ -27,8 +27,8 @@ export const RuleList: FC = () => {
|
||||
|
||||
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
|
||||
useEffect(() => {
|
||||
dispatch(fetchAllPromAndRulerRules());
|
||||
const interval = setInterval(() => dispatch(fetchAllPromAndRulerRules()), RULE_LIST_POLL_INTERVAL_MS);
|
||||
dispatch(fetchAllPromAndRulerRulesAction());
|
||||
const interval = setInterval(() => dispatch(fetchAllPromAndRulerRulesAction()), RULE_LIST_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
|
@ -9,10 +9,15 @@ export async function setRulerRuleGroup(
|
||||
namespace: string,
|
||||
group: RulerRuleGroupDTO
|
||||
): Promise<void> {
|
||||
await getBackendSrv().post(
|
||||
`/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`,
|
||||
group
|
||||
);
|
||||
await await getBackendSrv()
|
||||
.fetch<unknown>({
|
||||
method: 'POST',
|
||||
url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`,
|
||||
data: group,
|
||||
showErrorAlert: false,
|
||||
showSuccessAlert: false,
|
||||
})
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
// fetch all ruler rule namespaces and included groups
|
||||
|
@ -11,7 +11,7 @@ import { DataSourceType, isCloudRulesSource } from '../utils/datasource';
|
||||
import { Well } from './Well';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
expression: string;
|
||||
rulesSource: RulesSource;
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ export const HighlightedQuery: FC<{ language: 'promql' | 'logql'; expr: string }
|
||||
return <Editor plugins={plugins} value={slateValue} readOnly={true} />;
|
||||
};
|
||||
|
||||
export const RuleQuery: FC<Props> = ({ query, rulesSource }) => {
|
||||
export const Expression: FC<Props> = ({ expression: query, rulesSource }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
@ -0,0 +1,62 @@
|
||||
import { Cascader, CascaderOption } from '@grafana/ui';
|
||||
import React, { FC, useEffect, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
|
||||
import { fetchRulerRulesAction } from '../state/actions';
|
||||
|
||||
interface RuleGroupValue {
|
||||
namespace: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: RuleGroupValue;
|
||||
onChange: (value: RuleGroupValue) => void;
|
||||
dataSourceName: string;
|
||||
}
|
||||
|
||||
const stringifyValue = ({ namespace, group }: RuleGroupValue) => namespace + '|||' + group;
|
||||
const parseValue = (value: string): RuleGroupValue => {
|
||||
const [namespace, group] = value.split('|||');
|
||||
return { namespace, group };
|
||||
};
|
||||
|
||||
export const RuleGroupPicker: FC<Props> = ({ value, onChange, dataSourceName }) => {
|
||||
const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(fetchRulerRulesAction(dataSourceName));
|
||||
}, [dataSourceName, dispatch]);
|
||||
|
||||
const rulesConfig = rulerRequests[dataSourceName]?.result;
|
||||
|
||||
const options = useMemo((): CascaderOption[] => {
|
||||
if (rulesConfig) {
|
||||
return Object.entries(rulesConfig).map(([namespace, group]) => {
|
||||
return {
|
||||
label: namespace,
|
||||
value: namespace,
|
||||
items: group.map(({ name }) => {
|
||||
return { label: name, value: stringifyValue({ namespace, group: name }) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [rulesConfig]);
|
||||
|
||||
return (
|
||||
<Cascader
|
||||
placeholder="Select a rule group"
|
||||
onSelect={(value) => {
|
||||
console.log('selected', value);
|
||||
onChange(parseValue(value));
|
||||
}}
|
||||
initialValue={value ? stringifyValue(value) : undefined}
|
||||
displayAllSelectedLevels={true}
|
||||
separator=" > "
|
||||
options={options}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,53 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Field, FieldSet, Input, Select, useStyles, Label, InputControl } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { AlertRuleFormMethods } from './AlertRuleForm';
|
||||
|
||||
type Props = AlertRuleFormMethods;
|
||||
|
||||
enum TIME_OPTIONS {
|
||||
seconds = 's',
|
||||
minutes = 'm',
|
||||
hours = 'h',
|
||||
days = 'd',
|
||||
}
|
||||
|
||||
const timeOptions = Object.entries(TIME_OPTIONS).map(([key, value]) => ({
|
||||
label: key,
|
||||
value: value,
|
||||
}));
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
`,
|
||||
numberInput: css`
|
||||
width: 200px;
|
||||
& + & {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const AlertConditionsSection: FC<Props> = ({ register, control }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
return (
|
||||
<FieldSet label="Define alert conditions">
|
||||
<Label description="Required time for which the expression has to happen">For</Label>
|
||||
<div className={styles.flexRow}>
|
||||
<Field className={styles.numberInput}>
|
||||
<Input ref={register()} name="forTime" />
|
||||
</Field>
|
||||
<Field className={styles.numberInput}>
|
||||
<InputControl name="timeUnit" as={Select} options={timeOptions} control={control} />
|
||||
</Field>
|
||||
</div>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertConditionsSection;
|
@ -1,17 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { FieldSet, FormAPI } from '@grafana/ui';
|
||||
import LabelsField from './LabelsField';
|
||||
import AnnotationsField from './AnnotationsField';
|
||||
|
||||
interface Props extends FormAPI<{}> {}
|
||||
|
||||
const AlertDetails: FC<Props> = (props) => {
|
||||
return (
|
||||
<FieldSet label="Add details for your alert">
|
||||
<AnnotationsField {...props} />
|
||||
<LabelsField {...props} />
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertDetails;
|
@ -1,41 +1,145 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||
import { PageToolbar, ToolbarButton, stylesFactory, Form, FormAPI } from '@grafana/ui';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { PageToolbar, ToolbarButton, useStyles, CustomScrollbar, Spinner, Alert } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { config } from 'app/core/config';
|
||||
import AlertTypeSection from './AlertTypeSection';
|
||||
import AlertConditionsSection from './AlertConditionsSection';
|
||||
import AlertDetails from './AlertDetails';
|
||||
import Expression from './Expression';
|
||||
import { AlertTypeStep } from './AlertTypeStep';
|
||||
import { ConditionsStep } from './ConditionsStep';
|
||||
import { DetailsStep } from './DetailsStep';
|
||||
import { QueryStep } from './QueryStep';
|
||||
import { useForm, FormContext } from 'react-hook-form';
|
||||
|
||||
import { fetchRulerRulesNamespace, setRulerRuleGroup } from '../../api/ruler';
|
||||
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||
//import { locationService } from '@grafana/runtime';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { SAMPLE_QUERIES } from '../../mocks/grafana-queries';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { initialAsyncRequestState } from '../../utils/redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { saveRuleFormAction } from '../../state/actions';
|
||||
import { cleanUpAction } from 'app/core/actions/cleanUp';
|
||||
|
||||
type Props = {};
|
||||
|
||||
interface AlertRuleFormFields {
|
||||
name: string;
|
||||
type: SelectableValue;
|
||||
folder: SelectableValue;
|
||||
forTime: string;
|
||||
dataSource: SelectableValue;
|
||||
expression: string;
|
||||
timeUnit: SelectableValue;
|
||||
labels: Array<{ key: string; value: string }>;
|
||||
annotations: Array<{ key: SelectableValue; value: string }>;
|
||||
}
|
||||
const defaultValues: RuleFormValues = Object.freeze({
|
||||
name: '',
|
||||
labels: [{ key: '', value: '' }],
|
||||
annotations: [{ key: '', value: '' }],
|
||||
dataSourceName: null,
|
||||
|
||||
export type AlertRuleFormMethods = FormAPI<AlertRuleFormFields>;
|
||||
// threshold
|
||||
folder: null,
|
||||
queries: SAMPLE_QUERIES, // @TODO remove the sample eventually
|
||||
condition: '',
|
||||
noDataState: GrafanaAlertState.NoData,
|
||||
execErrState: GrafanaAlertState.Alerting,
|
||||
evaluateEvery: '1m',
|
||||
evaluateFor: '5m',
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
// system
|
||||
expression: '',
|
||||
forTime: 1,
|
||||
forTimeUnit: 'm',
|
||||
});
|
||||
|
||||
export const AlertRuleForm: FC<Props> = () => {
|
||||
const styles = useStyles(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch(cleanUpAction({ stateSelector: (state) => state.unifiedAlerting.ruleForm }));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const formAPI = useForm<RuleFormValues>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { handleSubmit, watch } = formAPI;
|
||||
|
||||
const type = watch('type');
|
||||
const dataSourceName = watch('dataSourceName');
|
||||
|
||||
const showStep2 = Boolean(dataSourceName && type);
|
||||
|
||||
const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
|
||||
|
||||
const submit = (values: RuleFormValues) => {
|
||||
dispatch(
|
||||
saveRuleFormAction({
|
||||
...values,
|
||||
annotations: values.annotations.filter(({ key }) => !!key),
|
||||
labels: values.labels.filter(({ key }) => !!key),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormContext {...formAPI}>
|
||||
<form onSubmit={handleSubmit(submit)} className={styles.form}>
|
||||
<PageToolbar title="Create alert rule" pageIcon="bell" className={styles.toolbar}>
|
||||
<ToolbarButton variant="default" disabled={submitState.loading}>
|
||||
Cancel
|
||||
</ToolbarButton>
|
||||
<ToolbarButton variant="primary" type="submit" disabled={submitState.loading}>
|
||||
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
|
||||
Save
|
||||
</ToolbarButton>
|
||||
<ToolbarButton variant="primary" disabled={submitState.loading}>
|
||||
{submitState.loading && <Spinner className={styles.buttonSpiner} inline={true} />}
|
||||
Save and exit
|
||||
</ToolbarButton>
|
||||
</PageToolbar>
|
||||
<div className={styles.contentOutter}>
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
<div className={styles.contentInner}>
|
||||
{submitState.error && (
|
||||
<Alert severity="error" title="Error saving rule">
|
||||
{submitState.error.message || (submitState.error as any)?.data?.message || String(submitState.error)}
|
||||
</Alert>
|
||||
)}
|
||||
<AlertTypeStep />
|
||||
{showStep2 && (
|
||||
<>
|
||||
<QueryStep />
|
||||
<ConditionsStep />
|
||||
<DetailsStep />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
</form>
|
||||
</FormContext>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
return {
|
||||
fullWidth: css`
|
||||
width: 100%;
|
||||
buttonSpiner: css`
|
||||
margin-right: ${theme.spacing.sm};
|
||||
`,
|
||||
formWrapper: css`
|
||||
padding: 0 ${theme.spacing.md};
|
||||
toolbar: css`
|
||||
padding-top: ${theme.spacing.sm};
|
||||
padding-bottom: ${theme.spacing.md};
|
||||
border-bottom: solid 1px ${theme.colors.border2};
|
||||
`,
|
||||
form: css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
contentInner: css`
|
||||
flex: 1;
|
||||
padding: ${theme.spacing.md};
|
||||
`,
|
||||
contentOutter: css`
|
||||
background: ${theme.colors.panelBg};
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
`,
|
||||
formInput: css`
|
||||
width: 400px;
|
||||
@ -49,81 +153,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
justify-content: flex-start;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
const AlertRuleForm: FC<Props> = () => {
|
||||
const styles = getStyles(config.theme);
|
||||
|
||||
const [folder, setFolder] = useState<{ namespace: string; group: string }>();
|
||||
|
||||
const handleSubmit = (alertRule: AlertRuleFormFields) => {
|
||||
const { name, expression, forTime, dataSource, timeUnit, labels, annotations } = alertRule;
|
||||
console.log('saving', alertRule);
|
||||
const { namespace, group: groupName } = folder || {};
|
||||
if (namespace && groupName) {
|
||||
fetchRulerRulesNamespace(dataSource?.value, namespace)
|
||||
.then((ruleGroup) => {
|
||||
const group: RulerRuleGroupDTO = ruleGroup.find(({ name }) => name === groupName) || {
|
||||
name: groupName,
|
||||
rules: [] as RulerRuleDTO[],
|
||||
};
|
||||
const alertRule: RulerRuleDTO = {
|
||||
alert: name,
|
||||
expr: expression,
|
||||
for: `${forTime}${timeUnit.value}`,
|
||||
labels: labels.reduce((acc, { key, value }) => {
|
||||
if (key && value) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
annotations: annotations.reduce((acc, { key, value }) => {
|
||||
if (key && value) {
|
||||
acc[key.value] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
};
|
||||
|
||||
group.rules = group?.rules.concat(alertRule);
|
||||
return setRulerRuleGroup(dataSource?.value, namespace, group);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Alert rule saved successfully');
|
||||
locationService.push('/alerting/list');
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className={styles.fullWidth}
|
||||
defaultValues={{ labels: [{ key: '', value: '' }], annotations: [{ key: {}, value: '' }] }}
|
||||
>
|
||||
{(formApi) => (
|
||||
<>
|
||||
<PageToolbar title="Create alert rule" pageIcon="bell">
|
||||
<ToolbarButton variant="primary" type="submit">
|
||||
Save
|
||||
</ToolbarButton>
|
||||
<ToolbarButton variant="primary">Save and exit</ToolbarButton>
|
||||
<a href="/alerting/list">
|
||||
<ToolbarButton variant="destructive" type="button">
|
||||
Cancel
|
||||
</ToolbarButton>
|
||||
</a>
|
||||
</PageToolbar>
|
||||
<div className={styles.formWrapper}>
|
||||
<AlertTypeSection {...formApi} setFolder={setFolder} />
|
||||
<Expression {...formApi} />
|
||||
<AlertConditionsSection {...formApi} />
|
||||
<AlertDetails {...formApi} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertRuleForm;
|
||||
|
@ -1,149 +0,0 @@
|
||||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||
import { Cascader, FieldSet, Field, Input, InputControl, stylesFactory, Select, CascaderOption } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { getAllDataSources } from '../../utils/config';
|
||||
import { fetchRulerRules } from '../../api/ruler';
|
||||
import { AlertRuleFormMethods } from './AlertRuleForm';
|
||||
import { getRulesDataSources } from '../../utils/datasource';
|
||||
|
||||
interface Props extends AlertRuleFormMethods {
|
||||
setFolder: ({ namespace, group }: { namespace: string; group: string }) => void;
|
||||
}
|
||||
|
||||
enum ALERT_TYPE {
|
||||
THRESHOLD = 'threshold',
|
||||
SYSTEM = 'system',
|
||||
HOST = 'host',
|
||||
}
|
||||
|
||||
const alertTypeOptions: SelectableValue[] = [
|
||||
{
|
||||
label: 'Threshold',
|
||||
value: ALERT_TYPE.THRESHOLD,
|
||||
description: 'Metric alert based on a defined threshold',
|
||||
},
|
||||
{
|
||||
label: 'System or application',
|
||||
value: ALERT_TYPE.SYSTEM,
|
||||
description: 'Alert based on a system or application behavior. Based on Prometheus.',
|
||||
},
|
||||
];
|
||||
|
||||
const AlertTypeSection: FC<Props> = ({ register, control, watch, setFolder, errors }) => {
|
||||
const styles = getStyles(config.theme);
|
||||
|
||||
const alertType = watch('type') as SelectableValue;
|
||||
const datasource = watch('dataSource') as SelectableValue;
|
||||
const dataSourceOptions = useDatasourceSelectOptions(alertType);
|
||||
const folderOptions = useFolderSelectOptions(datasource);
|
||||
|
||||
return (
|
||||
<FieldSet label="Alert type">
|
||||
<Field
|
||||
className={styles.formInput}
|
||||
label="Alert name"
|
||||
error={errors?.name?.message}
|
||||
invalid={!!errors.name?.message}
|
||||
>
|
||||
<Input ref={register({ required: { value: true, message: 'Must enter an alert name' } })} name="name" />
|
||||
</Field>
|
||||
<div className={styles.flexRow}>
|
||||
<Field label="Alert type" className={styles.formInput} error={errors.type?.message}>
|
||||
<InputControl as={Select} name="type" options={alertTypeOptions} control={control} />
|
||||
</Field>
|
||||
<Field className={styles.formInput} label="Select data source">
|
||||
<InputControl as={Select} name="dataSource" options={dataSourceOptions} control={control} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field className={styles.formInput}>
|
||||
<InputControl
|
||||
as={Cascader}
|
||||
displayAllSelectedLevels={true}
|
||||
separator=" > "
|
||||
name="folder"
|
||||
options={folderOptions}
|
||||
control={control}
|
||||
changeOnSelect={false}
|
||||
onSelect={(value: string) => {
|
||||
const [namespace, group] = value.split(' > ');
|
||||
setFolder({ namespace, group });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
const useDatasourceSelectOptions = (alertType: SelectableValue) => {
|
||||
const [datasourceOptions, setDataSourceOptions] = useState<SelectableValue[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let options = [] as ReturnType<typeof getAllDataSources>;
|
||||
if (alertType?.value === ALERT_TYPE.THRESHOLD) {
|
||||
options = getAllDataSources().filter(({ type }) => type !== 'datasource');
|
||||
} else if (alertType?.value === ALERT_TYPE.SYSTEM) {
|
||||
options = getRulesDataSources();
|
||||
}
|
||||
setDataSourceOptions(
|
||||
options.map(({ name, type }) => {
|
||||
return {
|
||||
label: name,
|
||||
value: name,
|
||||
description: type,
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [alertType?.value]);
|
||||
|
||||
return datasourceOptions;
|
||||
};
|
||||
|
||||
const useFolderSelectOptions = (datasource: SelectableValue) => {
|
||||
const [folderOptions, setFolderOptions] = useState<CascaderOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (datasource?.value) {
|
||||
fetchRulerRules(datasource?.value)
|
||||
.then((namespaces) => {
|
||||
const options: CascaderOption[] = Object.entries(namespaces).map(([namespace, group]) => {
|
||||
return {
|
||||
label: namespace,
|
||||
value: namespace,
|
||||
items: group.map(({ name }) => {
|
||||
return { label: name, value: `${namespace} > ${name}` };
|
||||
}),
|
||||
};
|
||||
});
|
||||
setFolderOptions(options);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.status === 404) {
|
||||
setFolderOptions([{ label: 'No folders found', value: '' }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [datasource?.value]);
|
||||
|
||||
return folderOptions;
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
formInput: css`
|
||||
width: 400px;
|
||||
& + & {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
export default AlertTypeSection;
|
@ -0,0 +1,175 @@
|
||||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import { DataSourceInstanceSettings, GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||
import { Field, Input, InputControl, Select, useStyles } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { DataSourcePicker, DataSourcePickerProps } from '@grafana/runtime';
|
||||
import { RuleGroupPicker } from '../RuleGroupPicker';
|
||||
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
|
||||
import { RuleFolderPicker } from './RuleFolderPicker';
|
||||
|
||||
const alertTypeOptions: SelectableValue[] = [
|
||||
{
|
||||
label: 'Threshold',
|
||||
value: RuleFormType.threshold,
|
||||
description: 'Metric alert based on a defined threshold',
|
||||
},
|
||||
{
|
||||
label: 'System or application',
|
||||
value: RuleFormType.system,
|
||||
description: 'Alert based on a system or application behavior. Based on Prometheus.',
|
||||
},
|
||||
];
|
||||
|
||||
export const AlertTypeStep: FC = () => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
const { register, control, watch, errors, setValue } = useFormContext<RuleFormValues>();
|
||||
|
||||
const ruleFormType = watch('type');
|
||||
const dataSourceName = watch('dataSourceName');
|
||||
|
||||
useEffect(() => {}, [ruleFormType]);
|
||||
|
||||
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
|
||||
|
||||
const dataSourceFilter = useCallback(
|
||||
(ds: DataSourceInstanceSettings): boolean => {
|
||||
if (ruleFormType === RuleFormType.threshold) {
|
||||
return !!ds.meta.alerting;
|
||||
} else {
|
||||
// filter out only rules sources that support ruler and thus can have alerts edited
|
||||
return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id);
|
||||
}
|
||||
},
|
||||
[ruleFormType, rulesSourcesWithRuler]
|
||||
);
|
||||
|
||||
return (
|
||||
<RuleEditorSection stepNo={1} title="Alert type">
|
||||
<Field
|
||||
className={styles.formInput}
|
||||
label="Alert name"
|
||||
error={errors?.name?.message}
|
||||
invalid={!!errors.name?.message}
|
||||
>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
ref={register({ required: { value: true, message: 'Must enter an alert name' } })}
|
||||
name="name"
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.flexRow}>
|
||||
<Field
|
||||
label="Alert type"
|
||||
className={styles.formInput}
|
||||
error={errors.type?.message}
|
||||
invalid={!!errors.type?.message}
|
||||
>
|
||||
<InputControl
|
||||
as={Select}
|
||||
name="type"
|
||||
options={alertTypeOptions}
|
||||
control={control}
|
||||
rules={{
|
||||
required: { value: true, message: 'Please select alert type' },
|
||||
}}
|
||||
onChange={(values: SelectableValue[]) => {
|
||||
const value = values[0]?.value;
|
||||
// when switching to system alerts, null out data source selection if it's not a rules source with ruler
|
||||
if (
|
||||
value === RuleFormType.system &&
|
||||
dataSourceName &&
|
||||
!rulesSourcesWithRuler.find(({ name }) => name === dataSourceName)
|
||||
) {
|
||||
setValue('dataSourceName', null);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
className={styles.formInput}
|
||||
label="Select data source"
|
||||
error={errors.dataSourceName?.message}
|
||||
invalid={!!errors.dataSourceName?.message}
|
||||
>
|
||||
<InputControl
|
||||
as={DataSourcePicker as React.ComponentType<Omit<DataSourcePickerProps, 'current'>>}
|
||||
valueName="current"
|
||||
filter={dataSourceFilter}
|
||||
name="dataSourceName"
|
||||
noDefault={true}
|
||||
control={control}
|
||||
alerting={true}
|
||||
rules={{
|
||||
required: { value: true, message: 'Please select a data source' },
|
||||
}}
|
||||
onChange={(ds: DataSourceInstanceSettings[]) => {
|
||||
// reset location if switching data sources, as differnet rules source will have different groups and namespaces
|
||||
setValue('location', undefined);
|
||||
return ds[0]?.name ?? null;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{ruleFormType === RuleFormType.system && (
|
||||
<Field
|
||||
label="Group"
|
||||
className={styles.formInput}
|
||||
error={errors.location?.message}
|
||||
invalid={!!errors.location?.message}
|
||||
>
|
||||
{dataSourceName ? (
|
||||
<InputControl
|
||||
as={RuleGroupPicker}
|
||||
name="location"
|
||||
control={control}
|
||||
dataSourceName={dataSourceName}
|
||||
rules={{
|
||||
required: { value: true, message: 'Please select a group' },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Select placeholder="Select a data source first" onChange={() => {}} disabled={true} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{ruleFormType === RuleFormType.threshold && (
|
||||
<Field
|
||||
label="Folder"
|
||||
className={styles.formInput}
|
||||
error={errors.folder?.message}
|
||||
invalid={!!errors.folder?.message}
|
||||
>
|
||||
<InputControl
|
||||
as={RuleFolderPicker}
|
||||
name="folder"
|
||||
enableCreateNew={true}
|
||||
enableReset={true}
|
||||
rules={{
|
||||
required: { value: true, message: 'Please select a folder' },
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
formInput: css`
|
||||
width: 330px;
|
||||
& + & {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
`,
|
||||
});
|
@ -0,0 +1,63 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Input, Select } from '@grafana/ui';
|
||||
import React, { FC, useMemo, useState } from 'react';
|
||||
|
||||
enum AnnotationOptions {
|
||||
description = 'Description',
|
||||
dashboard = 'Dashboard',
|
||||
summary = 'Summary',
|
||||
runbook = 'Runbook URL',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onChange: (value: string) => void;
|
||||
existingKeys: string[];
|
||||
|
||||
value?: string;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AnnotationKeyInput: FC<Props> = ({ value, onChange, existingKeys, width, className }) => {
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
|
||||
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 })),
|
||||
{ value: '__add__', label: '+ Custom name' },
|
||||
],
|
||||
[existingKeys]
|
||||
);
|
||||
|
||||
if (isCustom) {
|
||||
return (
|
||||
<Input
|
||||
width={width}
|
||||
autoFocus={true}
|
||||
value={value || ''}
|
||||
placeholder="key"
|
||||
className={className}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Select
|
||||
width={width}
|
||||
options={annotationOptions}
|
||||
value={value}
|
||||
className={className}
|
||||
onChange={(val: SelectableValue) => {
|
||||
const value = val?.value;
|
||||
if (value === '__add__') {
|
||||
setIsCustom(true);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
@ -1,31 +1,20 @@
|
||||
import React, { FC } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
FieldArray,
|
||||
FormAPI,
|
||||
IconButton,
|
||||
InputControl,
|
||||
Label,
|
||||
Select,
|
||||
TextArea,
|
||||
stylesFactory,
|
||||
} from '@grafana/ui';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { Button, Field, FieldArray, InputControl, Label, TextArea, useStyles } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { config } from 'app/core/config';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { AnnotationKeyInput } from './AnnotationKeyInput';
|
||||
|
||||
interface Props extends FormAPI<any> {}
|
||||
const AnnotationsField: FC = () => {
|
||||
const styles = useStyles(getStyles);
|
||||
const { control, register, watch, errors } = useFormContext<RuleFormValues>();
|
||||
const annotations = watch('annotations');
|
||||
|
||||
enum AnnotationOptions {
|
||||
summary = 'Summary',
|
||||
description = 'Description',
|
||||
runbook = 'Runbook url',
|
||||
}
|
||||
|
||||
const AnnotationsField: FC<Props> = ({ control, register }) => {
|
||||
const styles = getStyles(config.theme);
|
||||
const annotationOptions = Object.entries(AnnotationOptions).map(([key, value]) => ({ value: key, label: value }));
|
||||
const existingKeys = useCallback(
|
||||
(index: number): string[] => annotations.filter((_, idx) => idx !== index).map(({ key }) => key),
|
||||
[annotations]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -34,43 +23,50 @@ const AnnotationsField: FC<Props> = ({ control, register }) => {
|
||||
{({ fields, append, remove }) => {
|
||||
return (
|
||||
<div className={styles.flexColumn}>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div key={`${field.annotationKey}-${index}`} className={styles.flexRow}>
|
||||
<Field className={styles.annotationSelect}>
|
||||
<InputControl
|
||||
as={Select}
|
||||
name={`annotations[${index}].key`}
|
||||
options={annotationOptions}
|
||||
control={control}
|
||||
defaultValue={field.key}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={cx(styles.annotationTextArea, styles.flexRowItemMargin)}>
|
||||
<TextArea
|
||||
name={`annotations[${index}].value`}
|
||||
ref={register()}
|
||||
placeholder={`Text`}
|
||||
defaultValue={field.value}
|
||||
/>
|
||||
</Field>
|
||||
<IconButton
|
||||
className={styles.flexRowItemMargin}
|
||||
aria-label="delete annotation"
|
||||
name="trash-alt"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
{fields.map((field, index) => (
|
||||
<div key={`${field.annotationKey}-${index}`} className={styles.flexRow}>
|
||||
<Field
|
||||
className={styles.field}
|
||||
invalid={!!errors.annotations?.[index]?.key?.message}
|
||||
error={errors.annotations?.[index]?.key?.message}
|
||||
>
|
||||
<InputControl
|
||||
as={AnnotationKeyInput}
|
||||
width={15}
|
||||
name={`annotations[${index}].key`}
|
||||
existingKeys={existingKeys(index)}
|
||||
control={control}
|
||||
rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Field>
|
||||
<Field
|
||||
className={cx(styles.flexRowItemMargin, styles.field)}
|
||||
invalid={!!errors.annotations?.[index]?.value?.message}
|
||||
error={errors.annotations?.[index]?.value?.message}
|
||||
>
|
||||
<TextArea
|
||||
name={`annotations[${index}].value`}
|
||||
className={styles.annotationTextArea}
|
||||
ref={register({ required: { value: !!annotations[index]?.key, message: 'Required.' } })}
|
||||
placeholder={`value`}
|
||||
defaultValue={field.value}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
className={styles.flexRowItemMargin}
|
||||
aria-label="delete annotation"
|
||||
icon="trash-alt"
|
||||
variant="secondary"
|
||||
onClick={() => remove(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className={styles.addAnnotationsButton}
|
||||
icon="plus-circle"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
append({});
|
||||
}}
|
||||
@ -85,32 +81,31 @@ const AnnotationsField: FC<Props> = ({ control, register }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
annotationSelect: css`
|
||||
width: 120px;
|
||||
`,
|
||||
annotationTextArea: css`
|
||||
width: 450px;
|
||||
height: 76px;
|
||||
`,
|
||||
addAnnotationsButton: css`
|
||||
flex-grow: 0;
|
||||
align-self: flex-start;
|
||||
`,
|
||||
flexColumn: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
`,
|
||||
flexRowItemMargin: css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`,
|
||||
};
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
annotationTextArea: css`
|
||||
width: 450px;
|
||||
height: 76px;
|
||||
`,
|
||||
addAnnotationsButton: css`
|
||||
flex-grow: 0;
|
||||
align-self: flex-start;
|
||||
margin-left: 124px;
|
||||
`,
|
||||
flexColumn: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
field: css`
|
||||
margin-bottom: ${theme.spacing.xs};
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
`,
|
||||
flexRowItemMargin: css`
|
||||
margin-left: ${theme.spacing.xs};
|
||||
`,
|
||||
});
|
||||
|
||||
export default AnnotationsField;
|
||||
|
@ -0,0 +1,54 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Field, InputControl, Select } from '@grafana/ui';
|
||||
import React, { FC, useEffect, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
|
||||
export const ConditionField: FC = () => {
|
||||
const { watch, setValue, errors } = useFormContext<RuleFormValues>();
|
||||
|
||||
const queries = watch('queries');
|
||||
const condition = watch('condition');
|
||||
|
||||
const options = useMemo(
|
||||
(): SelectableValue[] =>
|
||||
queries
|
||||
.filter((q) => !!q.refId)
|
||||
.map((q) => ({
|
||||
value: q.refId,
|
||||
label: q.refId,
|
||||
})),
|
||||
[queries]
|
||||
);
|
||||
|
||||
// if option no longer exists, reset it
|
||||
useEffect(() => {
|
||||
if (condition && !options.find(({ value }) => value === condition)) {
|
||||
setValue('condition', null);
|
||||
}
|
||||
}, [condition, options, setValue]);
|
||||
|
||||
return (
|
||||
<Field
|
||||
label="Condition"
|
||||
description="The query or expression that will be alerted on"
|
||||
error={errors.condition?.message}
|
||||
invalid={!!errors.condition?.message}
|
||||
>
|
||||
<InputControl
|
||||
width={42}
|
||||
name="condition"
|
||||
as={Select}
|
||||
onChange={(values: SelectableValue[]) => values[0]?.value ?? null}
|
||||
options={options}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Please select the condition to alert on',
|
||||
},
|
||||
}}
|
||||
noOptionsMessage="No queries defined"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
@ -0,0 +1,149 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Field, Input, Select, useStyles, InputControl, InlineLabel, Switch } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
import { useFormContext, ValidationOptions } from 'react-hook-form';
|
||||
import { RuleFormType, RuleFormValues, TimeOptions } from '../../types/rule-form';
|
||||
import { ConditionField } from './ConditionField';
|
||||
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
||||
|
||||
const timeRangeValidationOptions: ValidationOptions = {
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required.',
|
||||
},
|
||||
pattern: {
|
||||
value: new RegExp(`^\\d+(${Object.values(TimeOptions).join('|')})$`),
|
||||
message: `Must be of format "(number)(unit)", for example "1m". Available units: ${Object.values(TimeOptions).join(
|
||||
', '
|
||||
)}`,
|
||||
},
|
||||
};
|
||||
|
||||
const timeOptions = Object.entries(TimeOptions).map(([key, value]) => ({
|
||||
label: key[0].toUpperCase() + key.slice(1),
|
||||
value: value,
|
||||
}));
|
||||
|
||||
export const ConditionsStep: FC = () => {
|
||||
const styles = useStyles(getStyles);
|
||||
const [showErrorHandling, setShowErrorHandling] = useState(false);
|
||||
const { register, control, watch, errors } = useFormContext<RuleFormValues>();
|
||||
|
||||
const type = watch('type');
|
||||
|
||||
return (
|
||||
<RuleEditorSection stepNo={3} title="Define alert conditions">
|
||||
{type === RuleFormType.threshold && (
|
||||
<>
|
||||
<ConditionField />
|
||||
<Field label="Evaluate">
|
||||
<div className={styles.flexRow}>
|
||||
<InlineLabel width={16} tooltip="How often the alert will be evaluated to see if it fires">
|
||||
Evaluate every
|
||||
</InlineLabel>
|
||||
<Field
|
||||
className={styles.inlineField}
|
||||
error={errors.evaluateEvery?.message}
|
||||
invalid={!!errors.evaluateEvery?.message}
|
||||
>
|
||||
<Input width={8} ref={register(timeRangeValidationOptions)} name="evaluateEvery" />
|
||||
</Field>
|
||||
<InlineLabel
|
||||
width={7}
|
||||
tooltip='Once condition is breached, alert will go into pending state. If it is pending for longer than the "for" value, it will become a firing alert.'
|
||||
>
|
||||
for
|
||||
</InlineLabel>
|
||||
<Field
|
||||
className={styles.inlineField}
|
||||
error={errors.evaluateFor?.message}
|
||||
invalid={!!errors.evaluateFor?.message}
|
||||
>
|
||||
<Input width={8} ref={register(timeRangeValidationOptions)} name="evaluateFor" />
|
||||
</Field>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Configure no data and error handling" horizontal={true} className={styles.switchField}>
|
||||
<Switch value={showErrorHandling} onChange={() => setShowErrorHandling(!showErrorHandling)} />
|
||||
</Field>
|
||||
{showErrorHandling && (
|
||||
<>
|
||||
<Field label="Alert state if no data or all values are null">
|
||||
<InputControl
|
||||
as={GrafanaAlertStatePicker}
|
||||
name="noDataState"
|
||||
width={42}
|
||||
onChange={(values) => values[0]?.value}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Alert state if execution error or timeout">
|
||||
<InputControl
|
||||
as={GrafanaAlertStatePicker}
|
||||
name="execErrState"
|
||||
width={42}
|
||||
onChange={(values) => values[0]?.value}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{type === RuleFormType.system && (
|
||||
<>
|
||||
<Field label="For" description="Expression has to be true for this long for the alert to be fired.">
|
||||
<div className={styles.flexRow}>
|
||||
<Field invalid={!!errors.forTime?.message} error={errors.forTime?.message} className={styles.inlineField}>
|
||||
<Input
|
||||
ref={register({ pattern: { value: /^\d+$/, message: 'Must be a postive integer.' } })}
|
||||
name="forTime"
|
||||
width={8}
|
||||
/>
|
||||
</Field>
|
||||
<InputControl
|
||||
name="forTimeUnit"
|
||||
as={Select}
|
||||
options={timeOptions}
|
||||
control={control}
|
||||
width={15}
|
||||
className={styles.timeUnit}
|
||||
onChange={(values) => values[0]?.value}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
inlineField: css`
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
`,
|
||||
numberInput: css`
|
||||
width: 200px;
|
||||
& + & {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
timeUnit: css`
|
||||
margin-left: ${theme.spacing.xs};
|
||||
`,
|
||||
switchField: css`
|
||||
display: inline-flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: ${theme.spacing.md};
|
||||
& > div:first-child {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
});
|
@ -0,0 +1,17 @@
|
||||
import React, { FC } from 'react';
|
||||
import LabelsField from './LabelsField';
|
||||
import AnnotationsField from './AnnotationsField';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
|
||||
export const DetailsStep: FC = () => {
|
||||
return (
|
||||
<RuleEditorSection
|
||||
stepNo={4}
|
||||
title="Add details for your alert"
|
||||
description="Write a summary and add labels to help you better manage your alerts"
|
||||
>
|
||||
<AnnotationsField />
|
||||
<LabelsField />
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Field, FieldSet, Input } from '@grafana/ui';
|
||||
import { AlertRuleFormMethods } from './AlertRuleForm';
|
||||
|
||||
type Props = AlertRuleFormMethods;
|
||||
|
||||
const Expression: FC<Props> = ({ register }) => {
|
||||
return (
|
||||
<FieldSet label="Create a query (expression) to be alerted on">
|
||||
<Field>
|
||||
<Input ref={register()} name="expression" placeholder="Enter a PromQL query here" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
export default Expression;
|
@ -0,0 +1,19 @@
|
||||
import { TextArea } from '@grafana/ui';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
dataSourceName: string; // will be a prometheus or loki datasource
|
||||
}
|
||||
|
||||
// @TODO implement proper prom/loki query editor here
|
||||
export const ExpressionEditor: FC<Props> = ({ value, onChange, dataSourceName }) => {
|
||||
return (
|
||||
<TextArea
|
||||
placeholder="Enter a promql expression"
|
||||
value={value}
|
||||
onChange={(evt) => onChange((evt.target as HTMLTextAreaElement).value)}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Select } from '@grafana/ui';
|
||||
import { SelectBaseProps } from '@grafana/ui/src/components/Select/types';
|
||||
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
type Props = Omit<SelectBaseProps<GrafanaAlertState>, 'options'>;
|
||||
|
||||
const options: SelectableValue[] = [
|
||||
{ value: GrafanaAlertState.Alerting, label: 'Alerting' },
|
||||
{ value: GrafanaAlertState.NoData, label: 'No Data' },
|
||||
{ value: GrafanaAlertState.KeepLastState, label: 'Keep Last State' },
|
||||
{ value: GrafanaAlertState.OK, label: 'OK' },
|
||||
];
|
||||
|
||||
export const GrafanaAlertStatePicker: FC<Props> = (props) => <Select options={options} {...props} />;
|
@ -0,0 +1,27 @@
|
||||
import { TextArea } from '@grafana/ui';
|
||||
import { GrafanaQuery } from 'app/types/unified-alerting-dto';
|
||||
import React, { FC, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value?: GrafanaQuery[];
|
||||
onChange: (value: GrafanaQuery[]) => void;
|
||||
}
|
||||
|
||||
// @TODO replace with actual query editor once it's done
|
||||
export const GrafanaQueryEditor: FC<Props> = ({ value, onChange }) => {
|
||||
const [content, setContent] = useState(JSON.stringify(value || [], null, 2));
|
||||
const onChangeHandler = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const val = (e.target as HTMLTextAreaElement).value;
|
||||
setContent(val);
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (parsed && Array.isArray(parsed)) {
|
||||
console.log('queries changed');
|
||||
onChange(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('invalid json');
|
||||
}
|
||||
};
|
||||
return <TextArea rows={20} value={content} onChange={onChangeHandler} />;
|
||||
};
|
@ -1,50 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Button, Field, FieldArray, FormAPI, Input, InlineLabel, IconButton, Label, stylesFactory } from '@grafana/ui';
|
||||
import React, { FC } from 'react';
|
||||
import { Button, Field, FieldArray, Input, InlineLabel, Label, useStyles } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { config } from 'app/core/config';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
|
||||
interface Props extends Pick<FormAPI<{}>, 'register' | 'control'> {
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LabelsField = (props: Props) => {
|
||||
const styles = getStyles(config.theme);
|
||||
const { register, control } = props;
|
||||
const LabelsField: FC<Props> = ({ className }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
const { register, control, watch, errors } = useFormContext<RuleFormValues>();
|
||||
const labels = watch('labels');
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className={cx(className, styles.wrapper)}>
|
||||
<Label>Custom Labels</Label>
|
||||
<FieldArray control={control} name="labels">
|
||||
{({ fields, append, remove }) => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.flexRow}>
|
||||
<InlineLabel width={12}>Labels</InlineLabel>
|
||||
<InlineLabel width={15}>Labels</InlineLabel>
|
||||
<div className={styles.flexColumn}>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<div className={cx(styles.flexRow, styles.centerAlignRow)}>
|
||||
<Field className={styles.labelInput}>
|
||||
<Field
|
||||
className={styles.labelInput}
|
||||
invalid={!!errors.labels?.[index]?.key?.message}
|
||||
error={errors.labels?.[index]?.key?.message}
|
||||
>
|
||||
<Input
|
||||
ref={register()}
|
||||
ref={register({ required: { value: !!labels[index]?.value, message: 'Required.' } })}
|
||||
name={`labels[${index}].key`}
|
||||
placeholder="key"
|
||||
defaultValue={field.key}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.equalSign}>=</div>
|
||||
<Field className={styles.labelInput}>
|
||||
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
||||
<Field
|
||||
className={styles.labelInput}
|
||||
invalid={!!errors.labels?.[index]?.value?.message}
|
||||
error={errors.labels?.[index]?.value?.message}
|
||||
>
|
||||
<Input
|
||||
ref={register()}
|
||||
ref={register({ required: { value: !!labels[index]?.key, message: 'Required.' } })}
|
||||
name={`labels[${index}].value`}
|
||||
placeholder="value"
|
||||
defaultValue={field.value}
|
||||
/>
|
||||
</Field>
|
||||
<IconButton
|
||||
<Button
|
||||
className={styles.deleteLabelButton}
|
||||
aria-label="delete label"
|
||||
name="trash-alt"
|
||||
icon="trash-alt"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
@ -58,7 +70,6 @@ const LabelsField = (props: Props) => {
|
||||
icon="plus-circle"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
append({});
|
||||
}}
|
||||
@ -75,8 +86,11 @@ const LabelsField = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
return {
|
||||
wrapper: css`
|
||||
margin-top: ${theme.spacing.md};
|
||||
`,
|
||||
flexColumn: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -90,6 +104,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
margin-left: ${theme.spacing.xs};
|
||||
}
|
||||
`,
|
||||
deleteLabelButton: css`
|
||||
margin-left: ${theme.spacing.xs};
|
||||
align-self: flex-start;
|
||||
`,
|
||||
addLabelButton: css`
|
||||
flex-grow: 0;
|
||||
align-self: flex-start;
|
||||
@ -98,21 +116,19 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
align-items: baseline;
|
||||
`,
|
||||
equalSign: css`
|
||||
width: ${theme.spacing.lg};
|
||||
height: ${theme.spacing.lg};
|
||||
padding: ${theme.spacing.sm};
|
||||
line-height: ${theme.spacing.sm};
|
||||
background-color: ${theme.colors.bg2};
|
||||
margin: 0 ${theme.spacing.xs};
|
||||
align-self: flex-start;
|
||||
width: 28px;
|
||||
justify-content: center;
|
||||
margin-left: ${theme.spacing.xs};
|
||||
`,
|
||||
labelInput: css`
|
||||
width: 200px;
|
||||
width: 207px;
|
||||
margin-bottom: ${theme.spacing.sm};
|
||||
& + & {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default LabelsField;
|
||||
|
@ -0,0 +1,47 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Field, InputControl } from '@grafana/ui';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { ExpressionEditor } from './ExpressionEditor';
|
||||
import { GrafanaQueryEditor } from './GrafanaQueryEditor';
|
||||
import { isArray } from 'lodash';
|
||||
|
||||
// @TODO get proper query editors in
|
||||
export const QueryStep: FC = () => {
|
||||
const { control, watch, errors } = useFormContext<RuleFormValues>();
|
||||
const type = watch('type');
|
||||
const dataSourceName = watch('dataSourceName');
|
||||
return (
|
||||
<RuleEditorSection stepNo={2} title="Create a query to be alerted on">
|
||||
{type === RuleFormType.system && dataSourceName && (
|
||||
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
|
||||
<InputControl
|
||||
name="expression"
|
||||
dataSourceName={dataSourceName}
|
||||
as={ExpressionEditor}
|
||||
control={control}
|
||||
rules={{
|
||||
required: { value: true, message: 'A valid expression is required' },
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{type === RuleFormType.threshold && (
|
||||
<Field
|
||||
invalid={!!errors.queries}
|
||||
error={(!!errors.queries && 'Must provide at least one valid query.') || undefined}
|
||||
>
|
||||
<InputControl
|
||||
name="queries"
|
||||
as={GrafanaQueryEditor}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (queries) => isArray(queries) && !!queries.length,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { FieldSet, useStyles } from '@grafana/ui';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
export interface RuleEditorSectionProps {
|
||||
title: string;
|
||||
stepNo: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const RuleEditorSection: FC<RuleEditorSectionProps> = ({ title, stepNo, children, description }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.parent}>
|
||||
<div>
|
||||
<span className={styles.stepNo}>{stepNo}</span>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<FieldSet label={title}>
|
||||
{description && <p className={styles.description}>{description}</p>}
|
||||
{children}
|
||||
</FieldSet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
parent: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`,
|
||||
description: css`
|
||||
margin-top: -${theme.spacing.md};
|
||||
`,
|
||||
stepNo: css`
|
||||
display: inline-block;
|
||||
width: ${theme.spacing.xl};
|
||||
height: ${theme.spacing.xl};
|
||||
line-height: ${theme.spacing.xl};
|
||||
border-radius: ${theme.spacing.md};
|
||||
text-align: center;
|
||||
color: ${theme.colors.textStrong};
|
||||
background-color: ${theme.colors.bg3};
|
||||
font-size: ${theme.typography.size.lg};
|
||||
margin-right: ${theme.spacing.md};
|
||||
`,
|
||||
content: css`
|
||||
flex: 1;
|
||||
`,
|
||||
});
|
@ -0,0 +1,15 @@
|
||||
import React, { FC } from 'react';
|
||||
import { FolderPicker, Props as FolderPickerProps } from 'app/core/components/Select/FolderPicker';
|
||||
|
||||
export interface Folder {
|
||||
title: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface Props extends Omit<FolderPickerProps, 'initiailTitle' | 'initialFolderId'> {
|
||||
value?: Folder;
|
||||
}
|
||||
|
||||
export const RuleFolderPicker: FC<Props> = ({ value, ...props }) => (
|
||||
<FolderPicker showRoot={false} allowEmpty={true} initialTitle={value?.title} initialFolderId={value?.id} {...props} />
|
||||
);
|
@ -3,13 +3,13 @@ import React, { FC } from 'react';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { RuleQuery } from '../RuleQuery';
|
||||
import { isAlertingRule } from '../../utils/rules';
|
||||
import { isCloudRulesSource } from '../../utils/datasource';
|
||||
import { Annotation } from '../Annotation';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
import { AlertInstancesTable } from './AlertInstancesTable';
|
||||
import { DetailsField } from './DetailsField';
|
||||
import { RuleQuery } from './RuleQuery';
|
||||
|
||||
interface Props {
|
||||
rule: CombinedRule;
|
||||
@ -33,7 +33,7 @@ export const RuleDetails: FC<Props> = ({ rule, rulesSource }) => {
|
||||
</DetailsField>
|
||||
)}
|
||||
<DetailsField label="Expression" className={cx({ [styles.exprRow]: !!annotations.length })} horizontal={true}>
|
||||
<RuleQuery query={rule.query} rulesSource={rulesSource} />
|
||||
<RuleQuery rule={rule} rulesSource={rulesSource} />
|
||||
</DetailsField>
|
||||
{annotations.map(([key, value]) => (
|
||||
<DetailsField key={key} label={key} horizontal={true}>
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||
import React, { FC } from 'react';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { Expression } from '../Expression';
|
||||
|
||||
interface Props {
|
||||
rule: CombinedRule;
|
||||
rulesSource: RulesSource;
|
||||
}
|
||||
|
||||
export const RuleQuery: FC<Props> = ({ rule, rulesSource }) => {
|
||||
const { rulerRule } = rule;
|
||||
|
||||
if (rulesSource !== GRAFANA_RULES_SOURCE_NAME) {
|
||||
return <Expression expression={rule.query} rulesSource={rulesSource} />;
|
||||
}
|
||||
if (rulerRule && isGrafanaRulerRule(rulerRule)) {
|
||||
// @TODO: better grafana queries vizualization
|
||||
return <pre>{JSON.stringify(rulerRule.grafana_alert.data, null, 2)}</pre>;
|
||||
}
|
||||
return <pre>@TODO: handle grafana prom rule case</pre>;
|
||||
};
|
@ -67,6 +67,8 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource
|
||||
actionIcons.push(<ActionIcon key="manage-perms" icon="lock" tooltip="manage permissions" />);
|
||||
}
|
||||
|
||||
const groupName = isCloudRulesSource(rulesSource) ? `${namespace} > ${group.name}` : namespace;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-testid="rule-group">
|
||||
<div className={styles.header} data-testid="rule-group-header">
|
||||
@ -82,10 +84,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource
|
||||
<img className={styles.dataSourceIcon} src={rulesSource.meta.info.logos.small} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<h6 className={styles.heading}>
|
||||
{namespace && `${namespace} > `}
|
||||
{group.name}
|
||||
</h6>
|
||||
<h6 className={styles.heading}>{groupName}</h6>
|
||||
<div className={styles.spacer} />
|
||||
<div className={styles.headerStats}>
|
||||
{group.rules.length} {pluralize('rule', group.rules.length)}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CombinedRule, CombinedRuleNamespace, Rule, RuleNamespace } from 'app/types/unified-alerting';
|
||||
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { getAllRulesSources, isCloudRulesSource } from '../utils/datasource';
|
||||
import { isAlertingRule, isAlertingRulerRule } from '../utils/rules';
|
||||
import { getAllRulesSources, isCloudRulesSource, isGrafanaRulesSource } from '../utils/datasource';
|
||||
import { isAlertingRule, isAlertingRulerRule, isRecordingRulerRule } from '../utils/rules';
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
|
||||
interface CacheValue {
|
||||
@ -49,13 +49,21 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
|
||||
annotations: rule.annotations || {},
|
||||
rulerRule: rule,
|
||||
}
|
||||
: {
|
||||
: isRecordingRulerRule(rule)
|
||||
? {
|
||||
name: rule.record,
|
||||
query: rule.expr,
|
||||
labels: rule.labels || {},
|
||||
annotations: {},
|
||||
rulerRule: rule,
|
||||
}
|
||||
: {
|
||||
name: rule.grafana_alert.title,
|
||||
query: '',
|
||||
labels: rule.grafana_alert.labels || {},
|
||||
annotations: rule.grafana_alert.annotations || {},
|
||||
rulerRule: rule,
|
||||
}
|
||||
),
|
||||
})),
|
||||
};
|
||||
@ -99,6 +107,17 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] {
|
||||
});
|
||||
|
||||
const result = Object.values(namespaces);
|
||||
if (isGrafanaRulesSource(rulesSource)) {
|
||||
// merge all groups in case of grafana
|
||||
result.forEach((namespace) => {
|
||||
namespace.groups = [
|
||||
{
|
||||
name: 'default',
|
||||
rules: namespace.groups.flatMap((g) => g.rules).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
cache.current[rulesSourceName] = { promRules, rulerRules, result };
|
||||
return result;
|
||||
})
|
||||
|
@ -0,0 +1,24 @@
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { fetchRulerRulesIfNotFetchedYet } from '../state/actions';
|
||||
import { getAllDataSources } from '../utils/config';
|
||||
import { DataSourceType, getRulesDataSources } from '../utils/datasource';
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
|
||||
export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] {
|
||||
const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// try fetching rules for each prometheus to see if it has ruler
|
||||
useEffect(() => {
|
||||
getAllDataSources()
|
||||
.filter((ds) => ds.type === DataSourceType.Prometheus)
|
||||
.forEach((ds) => dispatch(fetchRulerRulesIfNotFetchedYet(ds.name)));
|
||||
}, [dispatch]);
|
||||
|
||||
return useMemo(
|
||||
() => getRulesDataSources().filter((ds) => ds.type === DataSourceType.Loki || !!rulerRequests[ds.name]?.result),
|
||||
[rulerRequests]
|
||||
);
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
export const SAMPLE_QUERIES = [
|
||||
{
|
||||
refId: 'A',
|
||||
queryType: '',
|
||||
relativeTimeRange: {
|
||||
from: 30,
|
||||
to: 0,
|
||||
},
|
||||
model: {
|
||||
datasource: 'gdev-testdata',
|
||||
datasourceUid: '000000004',
|
||||
intervalMs: 1000,
|
||||
maxDataPoints: 100,
|
||||
pulseWave: {
|
||||
offCount: 6,
|
||||
offValue: 1,
|
||||
onCount: 6,
|
||||
onValue: 10,
|
||||
timeStep: 5,
|
||||
},
|
||||
refId: 'A',
|
||||
scenarioId: 'predictable_pulse',
|
||||
stringInput: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
refId: 'B',
|
||||
queryType: '',
|
||||
relativeTimeRange: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
model: {
|
||||
conditions: [
|
||||
{
|
||||
evaluator: {
|
||||
params: [3],
|
||||
type: 'gt',
|
||||
},
|
||||
operator: {
|
||||
type: 'and',
|
||||
},
|
||||
query: {
|
||||
Params: ['A'],
|
||||
},
|
||||
reducer: {
|
||||
type: 'last',
|
||||
},
|
||||
},
|
||||
],
|
||||
datasource: '__expr__',
|
||||
datasourceUid: '-100',
|
||||
intervalMs: 1000,
|
||||
maxDataPoints: 100,
|
||||
refId: 'B',
|
||||
type: 'classic_conditions',
|
||||
},
|
||||
},
|
||||
];
|
@ -1,14 +1,24 @@
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { ThunkResult } from 'app/types';
|
||||
import { RuleLocation, RuleNamespace } from 'app/types/unified-alerting';
|
||||
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager';
|
||||
import { fetchRules } from '../api/prometheus';
|
||||
import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesNamespace, setRulerRuleGroup } from '../api/ruler';
|
||||
import { getAllRulesSourceNames, isCloudRulesSource } from '../utils/datasource';
|
||||
import {
|
||||
deleteRulerRulesGroup,
|
||||
fetchRulerRules,
|
||||
fetchRulerRulesGroup,
|
||||
fetchRulerRulesNamespace,
|
||||
setRulerRuleGroup,
|
||||
} from '../api/ruler';
|
||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../utils/datasource';
|
||||
import { withSerializedError } from '../utils/redux';
|
||||
import { hashRulerRule } from '../utils/rules';
|
||||
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||
import { hashRulerRule, isRulerNotSupportedResponse } from '../utils/rules';
|
||||
|
||||
export const fetchPromRulesAction = createAsyncThunk(
|
||||
'unifiedalerting/fetchPromRules',
|
||||
@ -35,7 +45,18 @@ export const fetchSilencesAction = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export function fetchAllPromAndRulerRules(force = false): ThunkResult<void> {
|
||||
// this will only trigger ruler rules fetch if rules are not loaded yet and request is not in flight
|
||||
export function fetchRulerRulesIfNotFetchedYet(dataSourceName: string): ThunkResult<void> {
|
||||
return (dispatch, getStore) => {
|
||||
const { rulerRules } = getStore().unifiedAlerting;
|
||||
const resp = rulerRules[dataSourceName];
|
||||
if (!resp?.result && !(resp && isRulerNotSupportedResponse(resp)) && !resp?.loading) {
|
||||
dispatch(fetchRulerRulesAction(dataSourceName));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult<void> {
|
||||
return (dispatch, getStore) => {
|
||||
const { promRules, rulerRules } = getStore().unifiedAlerting;
|
||||
getAllRulesSourceNames().map((name) => {
|
||||
@ -78,3 +99,71 @@ export function deleteRuleAction(ruleLocation: RuleLocation): ThunkResult<void>
|
||||
return dispatch(fetchRulerRulesAction(ruleSourceName));
|
||||
};
|
||||
}
|
||||
|
||||
async function saveLotexRule(values: RuleFormValues): Promise<void> {
|
||||
const { dataSourceName, location } = values;
|
||||
if (dataSourceName && location) {
|
||||
const existingGroup = await fetchRulerRulesGroup(dataSourceName, location.namespace, location.group);
|
||||
const rule = formValuesToRulerAlertingRuleDTO(values);
|
||||
|
||||
// @TODO handle "update" case
|
||||
const payload: RulerRuleGroupDTO = existingGroup
|
||||
? {
|
||||
...existingGroup,
|
||||
rules: [...existingGroup.rules, rule],
|
||||
}
|
||||
: {
|
||||
name: location.group,
|
||||
rules: [rule],
|
||||
};
|
||||
|
||||
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
|
||||
} else {
|
||||
throw new Error('Data source and location must be specified');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGrafanaRule(values: RuleFormValues): Promise<void> {
|
||||
const { folder, evaluateEvery } = values;
|
||||
if (folder) {
|
||||
const existingNamespace = await fetchRulerRulesNamespace(GRAFANA_RULES_SOURCE_NAME, folder.title);
|
||||
|
||||
// set group name to rule name, but be super paranoid and check that this group does not already exist
|
||||
let group = values.name;
|
||||
let idx = 1;
|
||||
while (!!existingNamespace.find((g) => g.name === group)) {
|
||||
group = `${values.name}-${++idx}`;
|
||||
}
|
||||
|
||||
const rule = formValuesToRulerGrafanaRuleDTO(values);
|
||||
|
||||
const payload: RulerRuleGroupDTO = {
|
||||
name: group,
|
||||
interval: evaluateEvery,
|
||||
rules: [rule],
|
||||
};
|
||||
await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, payload);
|
||||
} else {
|
||||
throw new Error('Folder must be specified');
|
||||
}
|
||||
}
|
||||
|
||||
export const saveRuleFormAction = createAsyncThunk(
|
||||
'unifiedalerting/saveRuleForm',
|
||||
(values: RuleFormValues): Promise<void> =>
|
||||
withSerializedError(
|
||||
(async () => {
|
||||
const { type } = values;
|
||||
// in case of system (cortex/loki)
|
||||
if (type === RuleFormType.system) {
|
||||
await saveLotexRule(values);
|
||||
// in case of grafana managed
|
||||
} else if (type === RuleFormType.threshold) {
|
||||
await saveGrafanaRule(values);
|
||||
} else {
|
||||
throw new Error('Unexpected rule form type');
|
||||
}
|
||||
appEvents.emit(AppEvents.alertSuccess, ['Rule saved.']);
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import { createAsyncMapSlice } from '../utils/redux';
|
||||
import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
|
||||
import {
|
||||
fetchAlertManagerConfigAction,
|
||||
fetchPromRulesAction,
|
||||
fetchRulerRulesAction,
|
||||
fetchSilencesAction,
|
||||
saveRuleFormAction,
|
||||
} from './actions';
|
||||
|
||||
export const reducer = combineReducers({
|
||||
@ -17,6 +18,9 @@ export const reducer = combineReducers({
|
||||
).reducer,
|
||||
silences: createAsyncMapSlice('silences', fetchSilencesAction, (alertManagerSourceName) => alertManagerSourceName)
|
||||
.reducer,
|
||||
ruleForm: combineReducers({
|
||||
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
|
||||
}),
|
||||
});
|
||||
|
||||
export type UnifiedAlertingState = ReturnType<typeof reducer>;
|
||||
|
38
public/app/features/alerting/unified/types/rule-form.ts
Normal file
38
public/app/features/alerting/unified/types/rule-form.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { GrafanaQuery, GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
export enum RuleFormType {
|
||||
threshold = 'threshold',
|
||||
system = 'system',
|
||||
}
|
||||
|
||||
export enum TimeOptions {
|
||||
seconds = 's',
|
||||
minutes = 'm',
|
||||
hours = 'h',
|
||||
days = 'd',
|
||||
}
|
||||
|
||||
export interface RuleFormValues {
|
||||
// common
|
||||
name: string;
|
||||
type?: RuleFormType;
|
||||
dataSourceName: string | null;
|
||||
|
||||
labels: Array<{ key: string; value: string }>;
|
||||
annotations: Array<{ key: string; value: string }>;
|
||||
|
||||
// threshold alerts
|
||||
queries: GrafanaQuery[];
|
||||
condition: string | null; // refId of the query that gets alerted on
|
||||
noDataState: GrafanaAlertState;
|
||||
execErrState: GrafanaAlertState;
|
||||
folder: { title: string; id: number } | null;
|
||||
evaluateEvery: string;
|
||||
evaluateFor: string;
|
||||
|
||||
// system alerts
|
||||
location?: { namespace: string; group: string };
|
||||
forTime: number;
|
||||
forTimeUnit: string;
|
||||
expression: string;
|
||||
}
|
@ -51,6 +51,12 @@ export function isCloudRulesSource(rulesSource: RulesSource | string): rulesSour
|
||||
return rulesSource !== GRAFANA_RULES_SOURCE_NAME;
|
||||
}
|
||||
|
||||
export function isGrafanaRulesSource(
|
||||
rulesSource: RulesSource | string
|
||||
): rulesSource is typeof GRAFANA_RULES_SOURCE_NAME {
|
||||
return rulesSource === GRAFANA_RULES_SOURCE_NAME;
|
||||
}
|
||||
|
||||
export function getDataSourceByName(name: string): DataSourceInstanceSettings<DataSourceJsonData> | undefined {
|
||||
return getAllDataSources().find((source) => source.name === name);
|
||||
}
|
||||
|
@ -26,3 +26,10 @@ export function hash(value: string): number {
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function arrayToRecord(items: Array<{ key: string; value: string }>): Record<string, string> {
|
||||
return items.reduce<Record<string, string>>((rec, { key, value }) => {
|
||||
rec[key] = value;
|
||||
return rec;
|
||||
}, {});
|
||||
}
|
||||
|
39
public/app/features/alerting/unified/utils/rule-form.ts
Normal file
39
public/app/features/alerting/unified/utils/rule-form.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { describeInterval } from '@grafana/data/src/datetime/rangeutil';
|
||||
import { RulerAlertingRuleDTO, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import { arrayToRecord } from './misc';
|
||||
|
||||
export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerAlertingRuleDTO {
|
||||
const { name, expression, forTime, forTimeUnit } = values;
|
||||
return {
|
||||
alert: name,
|
||||
for: `${forTime}${forTimeUnit}`,
|
||||
annotations: arrayToRecord(values.annotations || []),
|
||||
labels: arrayToRecord(values.labels || []),
|
||||
expr: expression,
|
||||
};
|
||||
}
|
||||
|
||||
function intervalToSeconds(interval: string): number {
|
||||
const { sec, count } = describeInterval(interval);
|
||||
return sec * count;
|
||||
}
|
||||
|
||||
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): RulerGrafanaRuleDTO {
|
||||
const { name, condition, noDataState, execErrState, evaluateFor, queries } = values;
|
||||
if (condition) {
|
||||
return {
|
||||
grafana_alert: {
|
||||
title: name,
|
||||
condition,
|
||||
for: intervalToSeconds(evaluateFor), // @TODO provide raw string once backend supports it
|
||||
no_data_state: noDataState,
|
||||
exec_err_state: execErrState,
|
||||
data: queries,
|
||||
annotations: arrayToRecord(values.annotations || []),
|
||||
labels: arrayToRecord(values.labels || []),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Cannot create rule without specifying alert condition');
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
PromRuleType,
|
||||
RulerAlertingRuleDTO,
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRecordingRuleDTO,
|
||||
RulerRuleDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
@ -25,6 +26,10 @@ export function isRecordingRulerRule(rule: RulerRuleDTO): rule is RulerRecording
|
||||
return 'record' in rule;
|
||||
}
|
||||
|
||||
export function isGrafanaRulerRule(rule: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
|
||||
return 'grafana_alert' in rule;
|
||||
}
|
||||
|
||||
export function alertInstanceKey(alert: Alert): string {
|
||||
return JSON.stringify(alert.labels);
|
||||
}
|
||||
|
@ -164,6 +164,9 @@ export class DatasourceSrv implements DataSourceService {
|
||||
if (filters.metrics && !x.meta.metrics) {
|
||||
return false;
|
||||
}
|
||||
if (filters.alerting && !x.meta.alerting) {
|
||||
return false;
|
||||
}
|
||||
if (filters.tracing && !x.meta.tracing) {
|
||||
return false;
|
||||
}
|
||||
@ -173,6 +176,9 @@ export class DatasourceSrv implements DataSourceService {
|
||||
if (filters.pluginId && x.meta.id !== filters.pluginId) {
|
||||
return false;
|
||||
}
|
||||
if (filters.filter && !filters.filter(x)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!filters.all &&
|
||||
x.meta.metrics !== true &&
|
||||
@ -212,7 +218,7 @@ export class DatasourceSrv implements DataSourceService {
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (!filters.pluginId) {
|
||||
if (!filters.pluginId && !filters.alerting) {
|
||||
if (filters.mixed) {
|
||||
base.push(this.getInstanceSettings('-- Mixed --')!);
|
||||
}
|
||||
|
@ -393,10 +393,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
path: '/alerting/new',
|
||||
pageClass: 'page-alerting',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/rule-editor/AlertRuleForm'
|
||||
)
|
||||
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -410,10 +407,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
path: '/alerting/:id/edit',
|
||||
pageClass: 'page-alerting',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/rule-editor/AlertRuleForm'
|
||||
)
|
||||
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -69,7 +69,6 @@ export interface PromResponse<T> {
|
||||
export type PromRulesResponse = PromResponse<{ groups: PromRuleGroupDTO[] }>;
|
||||
|
||||
// Ruler rule DTOs
|
||||
|
||||
interface RulerRuleBaseDTO {
|
||||
expr: string;
|
||||
labels?: Labels;
|
||||
@ -85,7 +84,49 @@ export interface RulerAlertingRuleDTO extends RulerRuleBaseDTO {
|
||||
annotations?: Annotations;
|
||||
}
|
||||
|
||||
export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO;
|
||||
export enum GrafanaAlertState {
|
||||
Alerting = 'Alerting',
|
||||
NoData = 'NoData',
|
||||
KeepLastState = 'KeepLastState',
|
||||
OK = 'OK',
|
||||
}
|
||||
|
||||
export interface GrafanaQueryModel {
|
||||
datasource: string;
|
||||
datasourceUid: string;
|
||||
refId: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface GrafanaQuery {
|
||||
refId: string;
|
||||
queryType: string;
|
||||
relativeTimeRange: {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
model: GrafanaQueryModel;
|
||||
}
|
||||
|
||||
export interface GrafanaRuleDefinition {
|
||||
uid?: string;
|
||||
title: string;
|
||||
condition: string;
|
||||
for: number; //@TODO Sofia will update to accept string
|
||||
no_data_state: GrafanaAlertState;
|
||||
exec_err_state: GrafanaAlertState;
|
||||
data: GrafanaQuery[];
|
||||
annotations: Annotations;
|
||||
labels: Labels;
|
||||
}
|
||||
|
||||
export interface RulerGrafanaRuleDTO {
|
||||
grafana_alert: GrafanaRuleDefinition;
|
||||
// labels?: Labels; @TODO to be discussed
|
||||
// annotations?: Annotations;
|
||||
}
|
||||
|
||||
export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO | RulerGrafanaRuleDTO;
|
||||
|
||||
export type RulerRuleGroupDTO = {
|
||||
name: string;
|
||||
|
Loading…
Reference in New Issue
Block a user