diff --git a/frontend/package.json b/frontend/package.json index f116fbf379..868e95dce7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "babel-preset-react-app": "^10.0.0", "chart.js": "^3.4.0", "chartjs-adapter-date-fns": "^2.0.0", + "chartjs-plugin-annotation": "^1.4.0", "color": "^4.2.1", "cross-env": "^7.0.3", "css-loader": "4.3.0", diff --git a/frontend/public/locales/en-GB/rules.json b/frontend/public/locales/en-GB/rules.json new file mode 100644 index 0000000000..3e8ceb63cb --- /dev/null +++ b/frontend/public/locales/en-GB/rules.json @@ -0,0 +1,62 @@ +{ + "preview_chart_unexpected_error": "An unexpeced error occurred updating the chart, please check your query.", + "preview_chart_threshold_label": "Threshold", + "placeholder_label_key_pair": "Click here to enter a label (key value pairs)", + "button_yes": "Yes", + "button_no": "No", + "remove_label_confirm": "This action will remove all the labels. Do you want to proceed?", + "remove_label_success": "Labels cleared", + "alert_form_step1": "Step 1 - Define the metric", + "alert_form_step2": "Step 2 - Define Alert Conditions", + "alert_form_step3": "Step 3 - Alert Configuration", + "metric_query_max_limit": "Can not create query. You can create maximum of 5 queries", + "confirm_save_title": "Save Changes", + "confirm_save_content_part1": "Your alert built with", + "confirm_save_content_part2": "query will be saved. Press OK to confirm.", + "unexpected_error": "Sorry, an unexpected error occurred. Please contact your admin", + "rule_created": "Rule created successfully", + "rule_edited": "Rule edited successfully", + "expression_missing": "expression is missing in {{where}}", + "metricname_missing": "metric name is missing in {{where}}", + "condition_required": "at least one metric condition is required", + "alertname_required": "alert name is required", + "promql_required": "promql expression is required when query format is set to PromQL", + "button_savechanges": "Save Rule", + "button_createrule": "Create Rule", + "button_returntorules": "Return to rules", + "button_cancelchanges": "Cancel", + "button_discard": "Discard", + "text_condition1": "Send a notification when the metric is", + "text_condition2": "the threshold", + "text_condition3": "during the last", + "option_5min": "5 mins", + "option_10min": "10 mins", + "option_15min": "15 mins", + "option_60min": "60 mins", + "option_24hours": "24 hours", + "field_threshold": "Alert Threshold", + "option_allthetimes": "all the times", + "option_atleastonce": "at least once", + "option_onaverage": "on average", + "option_intotal": "in total", + "option_above": "above", + "option_below": "below", + "option_equal": "is equal to", + "option_notequal": "not equal to", + "button_query": "Query", + "button_formula": "Formula", + "tab_qb": "Query Builder", + "tab_promql": "PromQL", + "title_confirm": "Confirm", + "button_ok": "Yes", + "button_cancel": "No", + "field_promql_expr": "PromQL Expression", + "field_alert_name": "Alert Name", + "field_alert_desc": "Alert Description", + "field_labels": "Labels", + "field_severity": "Severity", + "option_critical": "Critical", + "option_error": "Error", + "option_warning": "Warning", + "option_info": "Info" +} \ No newline at end of file diff --git a/frontend/public/locales/en/rules.json b/frontend/public/locales/en/rules.json new file mode 100644 index 0000000000..3e8ceb63cb --- /dev/null +++ b/frontend/public/locales/en/rules.json @@ -0,0 +1,62 @@ +{ + "preview_chart_unexpected_error": "An unexpeced error occurred updating the chart, please check your query.", + "preview_chart_threshold_label": "Threshold", + "placeholder_label_key_pair": "Click here to enter a label (key value pairs)", + "button_yes": "Yes", + "button_no": "No", + "remove_label_confirm": "This action will remove all the labels. Do you want to proceed?", + "remove_label_success": "Labels cleared", + "alert_form_step1": "Step 1 - Define the metric", + "alert_form_step2": "Step 2 - Define Alert Conditions", + "alert_form_step3": "Step 3 - Alert Configuration", + "metric_query_max_limit": "Can not create query. You can create maximum of 5 queries", + "confirm_save_title": "Save Changes", + "confirm_save_content_part1": "Your alert built with", + "confirm_save_content_part2": "query will be saved. Press OK to confirm.", + "unexpected_error": "Sorry, an unexpected error occurred. Please contact your admin", + "rule_created": "Rule created successfully", + "rule_edited": "Rule edited successfully", + "expression_missing": "expression is missing in {{where}}", + "metricname_missing": "metric name is missing in {{where}}", + "condition_required": "at least one metric condition is required", + "alertname_required": "alert name is required", + "promql_required": "promql expression is required when query format is set to PromQL", + "button_savechanges": "Save Rule", + "button_createrule": "Create Rule", + "button_returntorules": "Return to rules", + "button_cancelchanges": "Cancel", + "button_discard": "Discard", + "text_condition1": "Send a notification when the metric is", + "text_condition2": "the threshold", + "text_condition3": "during the last", + "option_5min": "5 mins", + "option_10min": "10 mins", + "option_15min": "15 mins", + "option_60min": "60 mins", + "option_24hours": "24 hours", + "field_threshold": "Alert Threshold", + "option_allthetimes": "all the times", + "option_atleastonce": "at least once", + "option_onaverage": "on average", + "option_intotal": "in total", + "option_above": "above", + "option_below": "below", + "option_equal": "is equal to", + "option_notequal": "not equal to", + "button_query": "Query", + "button_formula": "Formula", + "tab_qb": "Query Builder", + "tab_promql": "PromQL", + "title_confirm": "Confirm", + "button_ok": "Yes", + "button_cancel": "No", + "field_promql_expr": "PromQL Expression", + "field_alert_name": "Alert Name", + "field_alert_desc": "Alert Description", + "field_labels": "Labels", + "field_severity": "Severity", + "option_critical": "Critical", + "option_error": "Error", + "option_warning": "Warning", + "option_info": "Info" +} \ No newline at end of file diff --git a/frontend/src/api/alerts/create.ts b/frontend/src/api/alerts/create.ts index 10dbff99b6..cad7917815 100644 --- a/frontend/src/api/alerts/create.ts +++ b/frontend/src/api/alerts/create.ts @@ -9,7 +9,7 @@ const create = async ( ): Promise | ErrorResponse> => { try { const response = await axios.post('/rules', { - data: props.query, + ...props.data, }); return { diff --git a/frontend/src/api/alerts/get.ts b/frontend/src/api/alerts/get.ts index aeddf67fd0..0437f8d1d8 100644 --- a/frontend/src/api/alerts/get.ts +++ b/frontend/src/api/alerts/get.ts @@ -14,7 +14,7 @@ const get = async ( statusCode: 200, error: null, message: response.data.status, - payload: response.data.data, + payload: response.data, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/alerts/put.ts b/frontend/src/api/alerts/put.ts index 15d4c7c698..b8c34e96bd 100644 --- a/frontend/src/api/alerts/put.ts +++ b/frontend/src/api/alerts/put.ts @@ -2,14 +2,14 @@ import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/alerts/put'; +import { PayloadProps, Props } from 'types/api/alerts/save'; const put = async ( props: Props, ): Promise | ErrorResponse> => { try { const response = await axios.put(`/rules/${props.id}`, { - data: props.data, + ...props.data, }); return { diff --git a/frontend/src/api/alerts/save.ts b/frontend/src/api/alerts/save.ts new file mode 100644 index 0000000000..229f0ae126 --- /dev/null +++ b/frontend/src/api/alerts/save.ts @@ -0,0 +1,17 @@ +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/alerts/save'; + +import create from './create'; +import put from './put'; + +const save = async ( + props: Props, +): Promise | ErrorResponse> => { + if (props.id && props.id > 0) { + return put({ ...props }); + } + + return create({ ...props }); +}; + +export default save; diff --git a/frontend/src/components/Graph/index.tsx b/frontend/src/components/Graph/index.tsx index 4bb76276c0..3df4de3caa 100644 --- a/frontend/src/components/Graph/index.tsx +++ b/frontend/src/components/Graph/index.tsx @@ -22,6 +22,7 @@ import { Tooltip, } from 'chart.js'; import * as chartjsAdapter from 'chartjs-adapter-date-fns'; +import annotationPlugin from 'chartjs-plugin-annotation'; import React, { useCallback, useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -50,6 +51,7 @@ Chart.register( SubTitle, BarController, BarElement, + annotationPlugin, ); function Graph({ @@ -62,6 +64,7 @@ function Graph({ name, yAxisUnit = 'short', forceReRender, + staticLine, }: GraphProps): JSX.Element { const { isDarkMode } = useSelector((state) => state.app); const chartRef = useRef(null); @@ -99,6 +102,30 @@ function Graph({ intersect: false, }, plugins: { + annotation: staticLine + ? { + annotations: [ + { + type: 'line', + yMin: staticLine.yMin, + yMax: staticLine.yMax, + borderColor: staticLine.borderColor, + borderWidth: staticLine.borderWidth, + label: { + content: staticLine.lineText, + enabled: true, + font: { + size: 10, + }, + borderWidth: 0, + position: 'start', + backgroundColor: 'transparent', + color: staticLine.textColor, + }, + }, + ], + } + : undefined, title: { display: title !== undefined, text: title, @@ -180,6 +207,7 @@ function Graph({ } }, }; + const chartHasData = hasData(data); const chartPlugins = []; @@ -205,6 +233,7 @@ function Graph({ name, yAxisUnit, onClickHandler, + staticLine, ]); useEffect(() => { @@ -229,6 +258,16 @@ interface GraphProps { name: string; yAxisUnit?: string; forceReRender?: boolean | null | number; + staticLine?: StaticLineProps | undefined; +} + +export interface StaticLineProps { + yMin: number | undefined; + yMax: number | undefined; + borderColor: string; + borderWidth: number; + lineText: string; + textColor: string; } export type GraphOnClickHandler = ( @@ -245,5 +284,6 @@ Graph.defaultProps = { onClickHandler: undefined, yAxisUnit: undefined, forceReRender: undefined, + staticLine: undefined, }; export default Graph; diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx new file mode 100644 index 0000000000..f527fbbdf1 --- /dev/null +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -0,0 +1,22 @@ +import { Form } from 'antd'; +import FormAlertRules from 'container/FormAlertRules'; +import React from 'react'; +import { AlertDef } from 'types/api/alerts/def'; + +function CreateRules({ initialValue }: CreateRulesProps): JSX.Element { + const [formInstance] = Form.useForm(); + + return ( + + ); +} + +interface CreateRulesProps { + initialValue: AlertDef; +} + +export default CreateRules; diff --git a/frontend/src/container/EditRules/index.tsx b/frontend/src/container/EditRules/index.tsx index e228af0a10..cf4a02e717 100644 --- a/frontend/src/container/EditRules/index.tsx +++ b/frontend/src/container/EditRules/index.tsx @@ -1,102 +1,23 @@ -import { SaveFilled } from '@ant-design/icons'; -import { Button, notification } from 'antd'; -import put from 'api/alerts/put'; -import Editor from 'components/Editor'; -import ROUTES from 'constants/routes'; -import { State } from 'hooks/useFetch'; -import history from 'lib/history'; -import React, { useCallback, useState } from 'react'; -import { PayloadProps } from 'types/api/alerts/get'; -import { PayloadProps as PutPayloadProps } from 'types/api/alerts/put'; +import { Form } from 'antd'; +import FormAlertRules from 'container/FormAlertRules'; +import React from 'react'; +import { AlertDef } from 'types/api/alerts/def'; -import { ButtonContainer } from './styles'; - -function EditRules({ initialData, ruleId }: EditRulesProps): JSX.Element { - const [value, setEditorValue] = useState(initialData); - const [notifications, Element] = notification.useNotification(); - const [editButtonState, setEditButtonState] = useState>( - { - error: false, - errorMessage: '', - loading: false, - success: false, - payload: undefined, - }, - ); - - const onClickHandler = useCallback(async () => { - try { - setEditButtonState((state) => ({ - ...state, - loading: true, - })); - const response = await put({ - data: value, - id: parseInt(ruleId, 10), - }); - - if (response.statusCode === 200) { - setEditButtonState((state) => ({ - ...state, - loading: false, - payload: response.payload, - })); - - notifications.success({ - message: 'Success', - description: 'Congrats. The alert was Edited correctly.', - }); - - setTimeout(() => { - history.push(ROUTES.LIST_ALL_ALERT); - }, 2000); - } else { - setEditButtonState((state) => ({ - ...state, - loading: false, - errorMessage: response.error || 'Something went wrong', - error: true, - })); - - notifications.error({ - message: 'Error', - description: - response.error || - 'Oops! Some issue occured in editing the alert please try again or contact support@signoz.io', - }); - } - } catch (error) { - notifications.error({ - message: 'Error', - description: - 'Oops! Some issue occured in editing the alert please try again or contact support@signoz.io', - }); - } - }, [value, ruleId, notifications]); +function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element { + const [formInstance] = Form.useForm(); return ( - <> - {Element} - - setEditorValue(value)} value={value} /> - - - - - + ); } interface EditRulesProps { - initialData: PayloadProps['data']; - ruleId: string; + initialValue: AlertDef; + ruleId: number; } export default EditRules; diff --git a/frontend/src/container/FormAlertRules/BasicInfo.tsx b/frontend/src/container/FormAlertRules/BasicInfo.tsx new file mode 100644 index 0000000000..2d1ce5eac4 --- /dev/null +++ b/frontend/src/container/FormAlertRules/BasicInfo.tsx @@ -0,0 +1,101 @@ +import { Select } from 'antd'; +import FormItem from 'antd/lib/form/FormItem'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertDef, Labels } from 'types/api/alerts/def'; + +import LabelSelect from './labels'; +import { + FormContainer, + InputSmall, + SeveritySelect, + StepHeading, + TextareaMedium, +} from './styles'; + +const { Option } = Select; + +interface BasicInfoProps { + alertDef: AlertDef; + setAlertDef: (a: AlertDef) => void; +} + +function BasicInfo({ alertDef, setAlertDef }: BasicInfoProps): JSX.Element { + // init namespace for translations + const { t } = useTranslation('rules'); + + return ( + <> + {t('alert_form_step3')} + + + { + const s = (value as string) || 'critical'; + setAlertDef({ + ...alertDef, + labels: { + ...alertDef.labels, + severity: s, + }, + }); + }} + > + + + + + + + + + { + setAlertDef({ + ...alertDef, + alert: e.target.value, + }); + }} + /> + + + { + setAlertDef({ + ...alertDef, + annotations: { + ...alertDef.annotations, + description: e.target.value, + }, + }); + }} + /> + + + { + setAlertDef({ + ...alertDef, + labels: { + ...l, + }, + }); + }} + initialValues={alertDef.labels} + /> + + + + ); +} + +export default BasicInfo; diff --git a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx new file mode 100644 index 0000000000..d3634d8da1 --- /dev/null +++ b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx @@ -0,0 +1,119 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { StaticLineProps } from 'components/Graph'; +import GridGraphComponent from 'container/GridGraphComponent'; +import { GRAPH_TYPES } from 'container/NewDashboard/ComponentsSlider'; +import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; +import { Time } from 'container/TopNav/DateTimeSelection/config'; +import getChartData from 'lib/getChartData'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { GetMetricQueryRange } from 'store/actions/dashboard/getQueryResults'; +import { Query } from 'types/api/dashboard/getAll'; +import { EQueryType } from 'types/common/dashboard'; + +import { ChartContainer, FailedMessageContainer } from './styles'; + +export interface ChartPreviewProps { + name: string; + query: Query | undefined; + graphType?: GRAPH_TYPES; + selectedTime?: timePreferenceType; + selectedInterval?: Time; + headline?: JSX.Element; + threshold?: number; +} + +function ChartPreview({ + name, + query, + graphType = 'TIME_SERIES', + selectedTime = 'GLOBAL_TIME', + selectedInterval = '5min', + headline, + threshold, +}: ChartPreviewProps): JSX.Element | null { + const { t } = useTranslation('rules'); + const staticLine: StaticLineProps | undefined = + threshold && threshold > 0 + ? { + yMin: threshold, + yMax: threshold, + borderColor: '#f14', + borderWidth: 1, + lineText: `${t('preview_chart_threshold_label')} (y=${threshold})`, + textColor: '#f14', + } + : undefined; + + const queryKey = JSON.stringify(query); + const queryResponse = useQuery({ + queryKey: ['chartPreview', queryKey, selectedInterval], + queryFn: () => + GetMetricQueryRange({ + query: query || { + queryType: 1, + promQL: [], + metricsBuilder: { + formulas: [], + queryBuilder: [], + }, + clickHouse: [], + }, + globalSelectedInterval: selectedInterval, + graphType, + selectedTime, + }), + enabled: + query != null && + (query.queryType !== EQueryType.PROM || + (query.promQL?.length > 0 && query.promQL[0].query !== '')), + }); + + const chartDataSet = queryResponse.isError + ? null + : getChartData({ + queryData: [ + { + queryData: queryResponse?.data?.payload?.data?.result + ? queryResponse?.data?.payload?.data?.result + : [], + }, + ], + }); + + return ( + + {headline} + {(queryResponse?.data?.error || queryResponse?.isError) && ( + + {' '} + {queryResponse?.data?.error || + queryResponse?.error || + t('preview_chart_unexpected_error')} + + )} + + {chartDataSet && !queryResponse.isError && ( + + )} + + ); +} + +ChartPreview.defaultProps = { + graphType: 'TIME_SERIES', + selectedTime: 'GLOBAL_TIME', + selectedInterval: '5min', + headline: undefined, + threshold: 0, +}; + +export default ChartPreview; diff --git a/frontend/src/container/FormAlertRules/ChartPreview/styles.ts b/frontend/src/container/FormAlertRules/ChartPreview/styles.ts new file mode 100644 index 0000000000..0f1617dc94 --- /dev/null +++ b/frontend/src/container/FormAlertRules/ChartPreview/styles.ts @@ -0,0 +1,28 @@ +import { Card, Tooltip } from 'antd'; +import styled from 'styled-components'; + +export const NotFoundContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 55vh; +`; + +export const FailedMessageContainer = styled(Tooltip)` + position: absolute; + top: 10px; + left: 10px; +`; + +export const ChartContainer = styled(Card)` + border-radius: 4px; + &&& { + position: relative; + } + + .ant-card-body { + padding: 1.5rem 0; + height: 57vh; + /* padding-bottom: 2rem; */ + } +`; diff --git a/frontend/src/container/FormAlertRules/PromqlSection.tsx b/frontend/src/container/FormAlertRules/PromqlSection.tsx new file mode 100644 index 0000000000..129e5bb92d --- /dev/null +++ b/frontend/src/container/FormAlertRules/PromqlSection.tsx @@ -0,0 +1,49 @@ +import PromQLQueryBuilder from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/query'; +import { IPromQLQueryHandleChange } from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/types'; +import React from 'react'; +import { IPromQueries } from 'types/api/alerts/compositeQuery'; + +function PromqlSection({ + promQueries, + setPromQueries, +}: PromqlSectionProps): JSX.Element { + const handlePromQLQueryChange = ({ + query, + legend, + toggleDelete, + }: IPromQLQueryHandleChange): void => { + let promQuery = promQueries.A; + + // todo(amol): how to remove query, make it null? + if (query) promQuery.query = query; + if (legend) promQuery.legend = legend; + if (toggleDelete) { + promQuery = { + query: '', + legend: '', + name: 'A', + disabled: false, + }; + } + setPromQueries({ + A: { + ...promQuery, + }, + }); + }; + return ( + + ); +} + +interface PromqlSectionProps { + promQueries: IPromQueries; + setPromQueries: (p: IPromQueries) => void; +} + +export default PromqlSection; diff --git a/frontend/src/container/FormAlertRules/QuerySection.tsx b/frontend/src/container/FormAlertRules/QuerySection.tsx new file mode 100644 index 0000000000..e58cdc3ace --- /dev/null +++ b/frontend/src/container/FormAlertRules/QuerySection.tsx @@ -0,0 +1,288 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { notification, Tabs } from 'antd'; +import MetricsBuilderFormula from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/formula'; +import MetricsBuilder from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/query'; +import { + IQueryBuilderFormulaHandleChange, + IQueryBuilderQueryHandleChange, +} from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/types'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + IFormulaQueries, + IMetricQueries, + IPromQueries, +} from 'types/api/alerts/compositeQuery'; +import { EAggregateOperator, EQueryType } from 'types/common/dashboard'; + +import PromqlSection from './PromqlSection'; +import { FormContainer, QueryButton, StepHeading } from './styles'; +import { toIMetricsBuilderQuery } from './utils'; + +const { TabPane } = Tabs; +function QuerySection({ + queryCategory, + setQueryCategory, + metricQueries, + setMetricQueries, + formulaQueries, + setFormulaQueries, + promQueries, + setPromQueries, +}: QuerySectionProps): JSX.Element { + // init namespace for translations + const { t } = useTranslation('rules'); + + const handleQueryCategoryChange = (s: string): void => { + if ( + parseInt(s, 10) === EQueryType.PROM && + (!promQueries || Object.keys(promQueries).length === 0) + ) { + setPromQueries({ + A: { + query: '', + stats: '', + name: 'A', + legend: '', + disabled: false, + }, + }); + } + + setQueryCategory(parseInt(s, 10)); + }; + + const getNextQueryLabel = useCallback((): string => { + let maxAscii = 0; + + Object.keys(metricQueries).forEach((key) => { + const n = key.charCodeAt(0); + if (n > maxAscii) { + maxAscii = n - 64; + } + }); + + return String.fromCharCode(64 + maxAscii + 1); + }, [metricQueries]); + + const handleFormulaChange = ({ + formulaIndex, + expression, + toggleDisable, + toggleDelete, + }: IQueryBuilderFormulaHandleChange): void => { + const allFormulas = formulaQueries; + const current = allFormulas[formulaIndex]; + if (expression) { + current.expression = expression; + } + + if (toggleDisable) { + current.disabled = !current.disabled; + } + + if (toggleDelete) { + delete allFormulas[formulaIndex]; + } else { + allFormulas[formulaIndex] = current; + } + + setFormulaQueries({ + ...allFormulas, + }); + }; + + const handleMetricQueryChange = ({ + queryIndex, + aggregateFunction, + metricName, + tagFilters, + groupBy, + legend, + toggleDisable, + toggleDelete, + }: IQueryBuilderQueryHandleChange): void => { + const allQueries = metricQueries; + const current = metricQueries[queryIndex]; + if (aggregateFunction) { + current.aggregateOperator = aggregateFunction; + } + if (metricName) { + current.metricName = metricName; + } + + if (tagFilters && current.tagFilters) { + current.tagFilters.items = tagFilters; + } + + if (legend) { + current.legend = legend; + } + + if (groupBy) { + current.groupBy = groupBy; + } + + if (toggleDisable) { + current.disabled = !current.disabled; + } + + if (toggleDelete) { + delete allQueries[queryIndex]; + } else { + allQueries[queryIndex] = current; + } + + setMetricQueries({ + ...allQueries, + }); + }; + + const addMetricQuery = useCallback(() => { + if (Object.keys(metricQueries).length > 5) { + notification.error({ + message: t('metric_query_max_limit'), + }); + return; + } + + const queryLabel = getNextQueryLabel(); + + const queries = metricQueries; + queries[queryLabel] = { + name: queryLabel, + queryName: queryLabel, + metricName: '', + formulaOnly: false, + aggregateOperator: EAggregateOperator.NOOP, + legend: '', + tagFilters: { + op: 'AND', + items: [], + }, + groupBy: [], + disabled: false, + expression: queryLabel, + }; + setMetricQueries({ ...queries }); + }, [t, getNextQueryLabel, metricQueries, setMetricQueries]); + + const addFormula = useCallback(() => { + // defaulting to F1 as only one formula is supported + // in alert definition + const queryLabel = 'F1'; + + const formulas = formulaQueries; + formulas[queryLabel] = { + queryName: queryLabel, + name: queryLabel, + formulaOnly: true, + expression: 'A', + disabled: false, + }; + + setFormulaQueries({ ...formulas }); + }, [formulaQueries, setFormulaQueries]); + + const renderPromqlUI = (): JSX.Element => { + return ( + + ); + }; + + const renderFormulaButton = (): JSX.Element => { + return ( + }> + {t('button_formula')} + + ); + }; + + const renderQueryButton = (): JSX.Element => { + return ( + }> + {t('button_query')} + + ); + }; + + const renderMetricUI = (): JSX.Element => { + return ( +
+ {metricQueries && + Object.keys(metricQueries).map((key: string) => { + // todo(amol): need to handle this in fetch + const current = metricQueries[key]; + current.name = key; + + return ( + + ); + })} + + {queryCategory !== EQueryType.PROM && renderQueryButton()} +
+ {formulaQueries && + Object.keys(formulaQueries).map((key: string) => { + // todo(amol): need to handle this in fetch + const current = formulaQueries[key]; + current.name = key; + + return ( + + ); + })} + {queryCategory === EQueryType.QUERY_BUILDER && + (!formulaQueries || Object.keys(formulaQueries).length === 0) && + metricQueries && + Object.keys(metricQueries).length > 0 && + renderFormulaButton()} +
+
+ ); + }; + return ( + <> + {t('alert_form_step1')} + +
+ + + + +
+ {queryCategory === EQueryType.PROM ? renderPromqlUI() : renderMetricUI()} +
+ + ); +} + +interface QuerySectionProps { + queryCategory: EQueryType; + setQueryCategory: (n: EQueryType) => void; + metricQueries: IMetricQueries; + setMetricQueries: (b: IMetricQueries) => void; + formulaQueries: IFormulaQueries; + setFormulaQueries: (b: IFormulaQueries) => void; + promQueries: IPromQueries; + setPromQueries: (p: IPromQueries) => void; +} + +export default QuerySection; diff --git a/frontend/src/container/FormAlertRules/RuleOptions.tsx b/frontend/src/container/FormAlertRules/RuleOptions.tsx new file mode 100644 index 0000000000..a4cc5844f4 --- /dev/null +++ b/frontend/src/container/FormAlertRules/RuleOptions.tsx @@ -0,0 +1,174 @@ +import { Select, Typography } from 'antd'; +import FormItem from 'antd/lib/form/FormItem'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertDef, + defaultCompareOp, + defaultEvalWindow, + defaultMatchType, +} from 'types/api/alerts/def'; +import { EQueryType } from 'types/common/dashboard'; + +import { + FormContainer, + InlineSelect, + StepHeading, + ThresholdInput, +} from './styles'; + +const { Option } = Select; + +function RuleOptions({ + alertDef, + setAlertDef, + queryCategory, +}: RuleOptionsProps): JSX.Element { + // init namespace for translations + const { t } = useTranslation('rules'); + + const handleMatchOptChange = (value: string | unknown): void => { + const m = (value as string) || alertDef.condition?.matchType; + setAlertDef({ + ...alertDef, + condition: { + ...alertDef.condition, + matchType: m, + }, + }); + }; + + const renderCompareOps = (): JSX.Element => { + return ( + { + const newOp = (value as string) || ''; + + setAlertDef({ + ...alertDef, + condition: { + ...alertDef.condition, + op: newOp, + }, + }); + }} + > + + + + + + ); + }; + + const renderThresholdMatchOpts = (): JSX.Element => { + return ( + handleMatchOptChange(value)} + > + + + + + + ); + }; + + const renderPromMatchOpts = (): JSX.Element => { + return ( + handleMatchOptChange(value)} + > + + + ); + }; + + const renderEvalWindows = (): JSX.Element => { + return ( + { + const ew = (value as string) || alertDef.evalWindow; + setAlertDef({ + ...alertDef, + evalWindow: ew, + }); + }} + > + {' '} + + + + + + + ); + }; + + const renderThresholdRuleOpts = (): JSX.Element => { + return ( + + + {t('text_condition1')} {renderCompareOps()} {t('text_condition2')}{' '} + {renderThresholdMatchOpts()} {t('text_condition3')} {renderEvalWindows()} + + + ); + }; + const renderPromRuleOptions = (): JSX.Element => { + return ( + + + {t('text_condition1')} {renderCompareOps()} {t('text_condition2')}{' '} + {renderPromMatchOpts()} + + + ); + }; + + return ( + <> + {t('alert_form_step2')} + + {queryCategory === EQueryType.PROM + ? renderPromRuleOptions() + : renderThresholdRuleOpts()} +
+ { + setAlertDef({ + ...alertDef, + condition: { + ...alertDef.condition, + target: (value as number) || undefined, + }, + }); + }} + /> +
+
+ + ); +} + +interface RuleOptionsProps { + alertDef: AlertDef; + setAlertDef: (a: AlertDef) => void; + queryCategory: EQueryType; +} +export default RuleOptions; diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx new file mode 100644 index 0000000000..1a1615fe52 --- /dev/null +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -0,0 +1,366 @@ +import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons'; +import { FormInstance, Modal, notification, Typography } from 'antd'; +import saveAlertApi from 'api/alerts/save'; +import ROUTES from 'constants/routes'; +import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag'; +import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag'; +import history from 'lib/history'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQueryClient } from 'react-query'; +import { + IFormulaQueries, + IMetricQueries, + IPromQueries, +} from 'types/api/alerts/compositeQuery'; +import { + AlertDef, + defaultEvalWindow, + defaultMatchType, +} from 'types/api/alerts/def'; +import { Query as StagedQuery } from 'types/api/dashboard/getAll'; +import { EQueryType } from 'types/common/dashboard'; + +import BasicInfo from './BasicInfo'; +import ChartPreview from './ChartPreview'; +import QuerySection from './QuerySection'; +import RuleOptions from './RuleOptions'; +import { ActionButton, ButtonContainer, MainFormContainer } from './styles'; +import useDebounce from './useDebounce'; +import { + prepareBuilderQueries, + prepareStagedQuery, + toChartInterval, + toFormulaQueries, + toMetricQueries, +} from './utils'; + +function FormAlertRules({ + formInstance, + initialValue, + ruleId, +}: FormAlertRuleProps): JSX.Element { + // init namespace for translations + const { t } = useTranslation('rules'); + + // use query client + const ruleCache = useQueryClient(); + + const [loading, setLoading] = useState(false); + + // alertDef holds the form values to be posted + const [alertDef, setAlertDef] = useState(initialValue); + + // initQuery contains initial query when component was mounted + const initQuery = initialValue?.condition?.compositeMetricQuery; + + const [queryCategory, setQueryCategory] = useState( + initQuery?.queryType, + ); + + // local state to handle metric queries + const [metricQueries, setMetricQueries] = useState( + toMetricQueries(initQuery?.builderQueries), + ); + + // local state to handle formula queries + const [formulaQueries, setFormulaQueries] = useState( + toFormulaQueries(initQuery?.builderQueries), + ); + + // local state to handle promql queries + const [promQueries, setPromQueries] = useState({ + ...initQuery?.promQueries, + }); + + // staged query is used to display chart preview + const [stagedQuery, setStagedQuery] = useState(); + const debouncedStagedQuery = useDebounce(stagedQuery, 500); + + // this use effect initiates staged query and + // other queries based on server data. + // useful when fetching of initial values (from api) + // is delayed + useEffect(() => { + const initQuery = initialValue?.condition?.compositeMetricQuery; + const typ = initQuery?.queryType; + + // extract metric query from builderQueries + const mq = toMetricQueries(initQuery?.builderQueries); + + // extract formula query from builderQueries + const fq = toFormulaQueries(initQuery?.builderQueries); + + // prepare staged query + const sq = prepareStagedQuery(typ, mq, fq, initQuery?.promQueries); + const pq = initQuery?.promQueries; + + setQueryCategory(typ); + setMetricQueries(mq); + setFormulaQueries(fq); + setPromQueries(pq); + setStagedQuery(sq); + setAlertDef(initialValue); + }, [initialValue]); + + // this useEffect updates staging query when + // any of its sub-parameters changes + useEffect(() => { + // prepare staged query + const sq: StagedQuery = prepareStagedQuery( + queryCategory, + metricQueries, + formulaQueries, + promQueries, + ); + setStagedQuery(sq); + }, [queryCategory, metricQueries, formulaQueries, promQueries]); + + const onCancelHandler = useCallback(() => { + history.replace(ROUTES.LIST_ALL_ALERT); + }, []); + + // onQueryCategoryChange handles changes to query category + // in state as well as sets additional defaults + const onQueryCategoryChange = (val: EQueryType): void => { + setQueryCategory(val); + if (val === EQueryType.PROM) { + setAlertDef({ + ...alertDef, + condition: { + ...alertDef.condition, + matchType: defaultMatchType, + }, + evalWindow: defaultEvalWindow, + }); + } + }; + + const isFormValid = useCallback((): boolean => { + let retval = true; + + if (!alertDef.alert || alertDef.alert === '') { + notification.error({ + message: 'Error', + description: t('alertname_required'), + }); + return false; + } + + if ( + queryCategory === EQueryType.PROM && + (!promQueries || Object.keys(promQueries).length === 0) + ) { + notification.error({ + message: 'Error', + description: t('promql_required'), + }); + return false; + } + + if ( + (queryCategory === EQueryType.QUERY_BUILDER && !metricQueries) || + Object.keys(metricQueries).length === 0 + ) { + notification.error({ + message: 'Error', + description: t('condition_required'), + }); + return false; + } + + Object.keys(metricQueries).forEach((key) => { + if (metricQueries[key].metricName === '') { + retval = false; + notification.error({ + message: 'Error', + description: t('metricname_missing', { where: metricQueries[key].name }), + }); + } + }); + + Object.keys(formulaQueries).forEach((key) => { + if (formulaQueries[key].expression === '') { + retval = false; + notification.error({ + message: 'Error', + description: t('expression_missing', formulaQueries[key].name), + }); + } + }); + + return retval; + }, [t, alertDef, queryCategory, metricQueries, formulaQueries, promQueries]); + + const saveRule = useCallback(async () => { + if (!isFormValid()) { + return; + } + + const postableAlert: AlertDef = { + ...alertDef, + source: window?.location.toString(), + ruleType: + queryCategory === EQueryType.PROM ? 'promql_rule' : 'threshold_rule', + condition: { + ...alertDef.condition, + compositeMetricQuery: { + builderQueries: prepareBuilderQueries(metricQueries, formulaQueries), + promQueries, + queryType: queryCategory, + }, + }, + }; + + setLoading(true); + try { + const apiReq = + ruleId && ruleId > 0 + ? { data: postableAlert, id: ruleId } + : { data: postableAlert }; + + const response = await saveAlertApi(apiReq); + + if (response.statusCode === 200) { + notification.success({ + message: 'Success', + description: + !ruleId || ruleId === 0 ? t('rule_created') : t('rule_edited'), + }); + console.log('invalidting cache'); + // invalidate rule in cache + ruleCache.invalidateQueries(['ruleId', ruleId]); + + setTimeout(() => { + history.replace(ROUTES.LIST_ALL_ALERT); + }, 2000); + } else { + notification.error({ + message: 'Error', + description: response.error || t('unexpected_error'), + }); + } + } catch (e) { + console.log('save alert api failed:', e); + notification.error({ + message: 'Error', + description: t('unexpected_error'), + }); + } + setLoading(false); + }, [ + t, + isFormValid, + queryCategory, + ruleId, + alertDef, + metricQueries, + formulaQueries, + promQueries, + ruleCache, + ]); + + const onSaveHandler = useCallback(async () => { + const content = ( + + {' '} + {t('confirm_save_content_part1')} {' '} + {t('confirm_save_content_part2')} + + ); + Modal.confirm({ + icon: , + title: t('confirm_save_title'), + centered: true, + content, + onOk() { + saveRule(); + }, + }); + }, [t, saveRule, queryCategory]); + + const renderBasicInfo = (): JSX.Element => ( + + ); + + const renderQBChartPreview = (): JSX.Element => { + return ( + } + name="" + threshold={alertDef.condition?.target} + query={debouncedStagedQuery} + selectedInterval={toChartInterval(alertDef.evalWindow)} + /> + ); + }; + + const renderPromChartPreview = (): JSX.Element => { + return ( + } + name="Chart Preview" + threshold={alertDef.condition?.target} + query={debouncedStagedQuery} + /> + ); + }; + + return ( + <> + {Element} + + {queryCategory === EQueryType.QUERY_BUILDER && renderQBChartPreview()} + {queryCategory === EQueryType.PROM && renderPromChartPreview()} + + + + + {renderBasicInfo()} + + } + > + {ruleId > 0 ? t('button_savechanges') : t('button_createrule')} + + + {ruleId === 0 && t('button_cancelchanges')} + {ruleId > 0 && t('button_discard')} + + + + + ); +} + +interface FormAlertRuleProps { + formInstance: FormInstance; + initialValue: AlertDef; + ruleId: number; +} + +export default FormAlertRules; diff --git a/frontend/src/container/FormAlertRules/labels/Labels.machine.ts b/frontend/src/container/FormAlertRules/labels/Labels.machine.ts new file mode 100644 index 0000000000..812a498c65 --- /dev/null +++ b/frontend/src/container/FormAlertRules/labels/Labels.machine.ts @@ -0,0 +1,49 @@ +import { createMachine } from 'xstate'; + +export const ResourceAttributesFilterMachine = + /** @xstate-layout N4IgpgJg5mDOIC5QBECGsAWAjA9qgThAAQDKYBAxhkQIIB2xAYgJYA2ALmPgHQAqqUANJgAngGIAcgFEAGr0SgADjljN2zHHQUgAHogAcAFgAM3AOz6ATAEYAzJdsA2Y4cOWAnABoQIxAFpDR2tuQ319AFYTcKdbFycAX3jvNExcAmIySmp6JjZOHn4hUTFNACFWAFd8bWVVdU1tPQQzY1MXY2tDdzNHM3dHd0NvXwR7biMTa313S0i+63DE5PRsPEJScnwqWgYiFg4uPgFhcQAlKRIpeSQQWrUNLRumx3Czbg8TR0sbS31jfUcw38fW47gBHmm4XCVms3SWIBSq3SGyyO1yBx4AHlFFxUOwcPhJLJrkoVPcGk9ENYFuF3i5YR0wtEHECEAEgiEmV8zH1DLYzHZ4Yi0utMltsrt9vluNjcfjCWVKtUbnd6o9QE1rMYBtxbGFvsZ3NrZj1WdYOfotUZLX0XEFHEKViKMpttjk9nlDrL8HiCWJzpcSbcyWrGoh3NCQj0zK53P1ph1WeFLLqnJZ2s5vmZLA6kginWsXaj3VLDoUAGqoSpgEp0cpVGohh5hhDWDy0sz8zruakzamWVm-Qyg362V5-AZOayO1KFlHitEejFHKCV6v+i5XRt1ZuU1s52zjNOOaZfdOWIY+RDZ0Hc6ZmKEXqyLPPCudit2Sz08ACSEFYNbSHI27kuquiIOEjiONwjJgrM3RWJYZisgEIJgnYPTmuEdi2OaiR5nQOAQHA2hvsiH4Sui0qFCcIGhnuLSmP0YJuJ2xjJsmKELG8XZTK0tjdHG06vgW5GupRS7St6vrKqSO4UhqVL8TBWp8o4eqdl0A5Xmy3G6gK56-B4uERDOSKiuJi6lgUAhrhUYB0buimtrEKZBDYrxaS0OZca8+ltheybOI4hivGZzrzp+VGHH+AGOQp4EIHy+ghNYnawtG4TsbYvk8QKfHGAJfQ9uF76WSW37xWBTSGJ0qXpd0vRZdEKGPqC2YeO2-zfO4+HxEAA */ + createMachine({ + tsTypes: {} as import('./Labels.machine.typegen').Typegen0, + initial: 'Idle', + states: { + LabelKey: { + on: { + NEXT: { + actions: 'onSelectLabelValue', + target: 'LabelValue', + }, + onBlur: { + actions: 'onSelectLabelValue', + target: 'LabelValue', + }, + RESET: { + target: 'Idle', + }, + }, + }, + LabelValue: { + on: { + NEXT: { + actions: ['onValidateQuery'], + }, + onBlur: { + actions: ['onValidateQuery'], + // target: 'Idle', + }, + RESET: { + target: 'Idle', + }, + }, + }, + Idle: { + on: { + NEXT: { + actions: 'onSelectLabelKey', + description: 'Enter a label key', + target: 'LabelKey', + }, + }, + }, + }, + id: 'Label Key Values', + }); diff --git a/frontend/src/container/FormAlertRules/labels/Labels.machine.typegen.ts b/frontend/src/container/FormAlertRules/labels/Labels.machine.typegen.ts new file mode 100644 index 0000000000..f31469f659 --- /dev/null +++ b/frontend/src/container/FormAlertRules/labels/Labels.machine.typegen.ts @@ -0,0 +1,25 @@ +// This file was automatically generated. Edits will be overwritten + +export interface Typegen0 { + '@@xstate/typegen': true; + eventsCausingActions: { + onSelectLabelValue: 'NEXT' | 'onBlur'; + onValidateQuery: 'NEXT' | 'onBlur'; + onSelectLabelKey: 'NEXT'; + }; + internalEvents: { + 'xstate.init': { type: 'xstate.init' }; + }; + invokeSrcNameMap: {}; + missingImplementations: { + actions: 'onSelectLabelValue' | 'onValidateQuery' | 'onSelectLabelKey'; + services: never; + guards: never; + delays: never; + }; + eventsCausingServices: {}; + eventsCausingGuards: {}; + eventsCausingDelays: {}; + matchesStates: 'LabelKey' | 'LabelValue' | 'Idle'; + tags: never; +} diff --git a/frontend/src/container/FormAlertRules/labels/QueryChip.tsx b/frontend/src/container/FormAlertRules/labels/QueryChip.tsx new file mode 100644 index 0000000000..47e4c956ff --- /dev/null +++ b/frontend/src/container/FormAlertRules/labels/QueryChip.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { QueryChipContainer, QueryChipItem } from './styles'; +import { ILabelRecord } from './types'; + +interface QueryChipProps { + queryData: ILabelRecord; + onRemove: (id: string) => void; +} + +export default function QueryChip({ + queryData, + onRemove, +}: QueryChipProps): JSX.Element { + const { key, value } = queryData; + return ( + + onRemove(key)} + > + {key}: {value} + + + ); +} diff --git a/frontend/src/container/FormAlertRules/labels/index.tsx b/frontend/src/container/FormAlertRules/labels/index.tsx new file mode 100644 index 0000000000..1ce72d306c --- /dev/null +++ b/frontend/src/container/FormAlertRules/labels/index.tsx @@ -0,0 +1,164 @@ +import { + CloseCircleFilled, + ExclamationCircleOutlined, +} from '@ant-design/icons'; +import { useMachine } from '@xstate/react'; +import { Button, Input, message, Modal } from 'antd'; +import { map } from 'lodash-es'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { Labels } from 'types/api/alerts/def'; +import AppReducer from 'types/reducer/app'; +import { v4 as uuid } from 'uuid'; + +import { ResourceAttributesFilterMachine } from './Labels.machine'; +import QueryChip from './QueryChip'; +import { QueryChipItem, SearchContainer } from './styles'; +import { ILabelRecord } from './types'; +import { createQuery, flattenLabels, prepareLabels } from './utils'; + +interface LabelSelectProps { + onSetLabels: (q: Labels) => void; + initialValues: Labels | undefined; +} + +function LabelSelect({ + onSetLabels, + initialValues, +}: LabelSelectProps): JSX.Element | null { + const { t } = useTranslation('rules'); + const { isDarkMode } = useSelector((state) => state.app); + const [currentVal, setCurrentVal] = useState(''); + const [staging, setStaging] = useState([]); + const [queries, setQueries] = useState( + initialValues ? flattenLabels(initialValues) : [], + ); + + const dispatchChanges = (updatedRecs: ILabelRecord[]): void => { + onSetLabels(prepareLabels(updatedRecs, initialValues)); + setQueries(updatedRecs); + }; + + const [state, send] = useMachine(ResourceAttributesFilterMachine, { + actions: { + onSelectLabelKey: () => {}, + onSelectLabelValue: () => { + if (currentVal !== '') { + setStaging((prevState) => [...prevState, currentVal]); + } else { + return; + } + setCurrentVal(''); + }, + onValidateQuery: (): void => { + if (currentVal === '') { + return; + } + + const generatedQuery = createQuery([...staging, currentVal]); + + if (generatedQuery) { + dispatchChanges([...queries, generatedQuery]); + setStaging([]); + setCurrentVal(''); + send('RESET'); + } + }, + }, + }); + + const handleFocus = (): void => { + if (state.value === 'Idle') { + send('NEXT'); + } + }; + + const handleBlur = useCallback((): void => { + if (staging.length === 1 && staging[0] !== undefined) { + send('onBlur'); + } + }, [send, staging]); + + useEffect(() => { + handleBlur(); + }, [handleBlur]); + + const handleChange = (e: React.ChangeEvent): void => { + setCurrentVal(e.target?.value); + }; + + const handleClose = (key: string): void => { + dispatchChanges(queries.filter((queryData) => queryData.key !== key)); + }; + + const handleClearAll = (): void => { + Modal.confirm({ + title: 'Confirm', + icon: , + content: t('remove_label_confirm'), + onOk() { + send('RESET'); + dispatchChanges([]); + setStaging([]); + message.success(t('remove_label_success')); + }, + okText: t('button_yes'), + cancelText: t('button_no'), + }); + }; + const renderPlaceholder = useCallback((): string => { + if (state.value === 'LabelKey') return 'Enter a label key then press ENTER.'; + if (state.value === 'LabelValue') + return `Enter a value for label key(${staging[0]}) then press ENTER.`; + return t('placeholder_label_key_pair'); + }, [t, state, staging]); + return ( + +
+ {queries.length > 0 && + map( + queries, + (query): JSX.Element => { + return ( + + ); + }, + )} +
+
+ {map(staging, (item) => { + return {item}; + })} +
+ +
+ { + if (e.key === 'Enter' || e.code === 'Enter') { + send('NEXT'); + } + }} + bordered={false} + value={currentVal as never} + style={{ flex: 1 }} + onFocus={handleFocus} + onBlur={handleBlur} + /> + + {queries.length || staging.length || currentVal ? ( +
+
+ ); +} + +export default LabelSelect; diff --git a/frontend/src/container/FormAlertRules/labels/styles.ts b/frontend/src/container/FormAlertRules/labels/styles.ts new file mode 100644 index 0000000000..04d6871315 --- /dev/null +++ b/frontend/src/container/FormAlertRules/labels/styles.ts @@ -0,0 +1,35 @@ +import { grey } from '@ant-design/colors'; +import { Tag } from 'antd'; +import styled from 'styled-components'; + +interface SearchContainerProps { + isDarkMode: boolean; + disabled: boolean; +} + +export const SearchContainer = styled.div` + width: 70%; + border-radisu: 4px; + background: ${({ isDarkMode }): string => (isDarkMode ? '#000' : '#fff')}; + flex: 1; + display: flex; + flex-direction: column; + padding: 0.2rem; + border: 1px solid #ccc5; + ${({ disabled }): string => (disabled ? `cursor: not-allowed;` : '')} +`; + +export const QueryChipContainer = styled.span` + display: flex; + align-items: center; + margin-right: 0.5rem; + &:hover { + & > * { + background: ${grey.primary}44; + } + } +`; + +export const QueryChipItem = styled(Tag)` + margin-right: 0.1rem; +`; diff --git a/frontend/src/container/FormAlertRules/labels/types.ts b/frontend/src/container/FormAlertRules/labels/types.ts new file mode 100644 index 0000000000..b10fc3fded --- /dev/null +++ b/frontend/src/container/FormAlertRules/labels/types.ts @@ -0,0 +1,9 @@ +export interface ILabelRecord { + key: string; + value: string; +} + +export interface IOption { + label: string; + value: string; +} diff --git a/frontend/src/container/FormAlertRules/labels/utils.ts b/frontend/src/container/FormAlertRules/labels/utils.ts new file mode 100644 index 0000000000..1a2943f3ee --- /dev/null +++ b/frontend/src/container/FormAlertRules/labels/utils.ts @@ -0,0 +1,54 @@ +import { Labels } from 'types/api/alerts/def'; + +import { ILabelRecord } from './types'; + +const hiddenLabels = ['severity', 'description']; + +export const createQuery = ( + selectedItems: Array = [], +): ILabelRecord | null => { + if (selectedItems.length === 2) { + return { + key: selectedItems[0] as string, + value: selectedItems[1] as string, + }; + } + return null; +}; + +export const flattenLabels = (labels: Labels): ILabelRecord[] => { + const recs: ILabelRecord[] = []; + + Object.keys(labels).forEach((key) => { + if (!hiddenLabels.includes(key)) { + recs.push({ + key, + value: labels[key], + }); + } + }); + + return recs; +}; + +export const prepareLabels = ( + recs: ILabelRecord[], + alertLabels: Labels | undefined, +): Labels => { + const labels: Labels = {}; + + recs.forEach((rec) => { + if (!hiddenLabels.includes(rec.key)) { + labels[rec.key] = rec.value; + } + }); + if (alertLabels) { + Object.keys(alertLabels).forEach((key) => { + if (hiddenLabels.includes(key)) { + labels[key] = alertLabels[key]; + } + }); + } + + return labels; +}; diff --git a/frontend/src/container/FormAlertRules/styles.ts b/frontend/src/container/FormAlertRules/styles.ts new file mode 100644 index 0000000000..1626becfa6 --- /dev/null +++ b/frontend/src/container/FormAlertRules/styles.ts @@ -0,0 +1,90 @@ +import { Button, Card, Form, Input, InputNumber, Select } from 'antd'; +import TextArea from 'antd/lib/input/TextArea'; +import styled from 'styled-components'; + +export const MainFormContainer = styled(Form)` + max-width: 900px; +`; + +export const ButtonContainer = styled.div` + &&& { + display: flex; + justify-content: flex-start; + align-items: center; + margin-top: 1rem; + margin-bottom: 3rem; + } +`; + +export const ActionButton = styled(Button)` + margin-right: 1rem; +`; + +export const QueryButton = styled(Button)` + &&& { + display: flex; + align-items: center; + margin-right: 1rem; + } +`; + +export const QueryContainer = styled(Card)` + &&& { + margin-top: 1rem; + min-height: 23.5%; + } +`; + +export const Container = styled.div` + margin-top: 1rem; + display: flex; + flex-direction: column; +`; + +export const StepHeading = styled.p` + margin-top: 1rem; + font-weight: bold; +`; + +export const InlineSelect = styled(Select)` + display: inline-block; + width: 10% !important; + margin-left: 0.2em; + margin-right: 0.2em; +`; + +export const SeveritySelect = styled(Select)` + width: 15% !important; +`; + +export const InputSmall = styled(Input)` + width: 40% !important; +`; + +export const FormContainer = styled.div` + padding: 2em; + margin-top: 1rem; + display: flex; + flex-direction: column; + background: #141414; + border-radius: 4px; + border: 1px solid #303030; +`; + +export const ThresholdInput = styled(InputNumber)` + & > div { + display: flex; + align-items: center; + & > .ant-input-number-group-addon { + width: 130px; + } + & > .ant-input-number { + width: 50%; + margin-left: 1em; + } + } +`; + +export const TextareaMedium = styled(TextArea)` + width: 70%; +`; diff --git a/frontend/src/container/FormAlertRules/useDebounce.js b/frontend/src/container/FormAlertRules/useDebounce.js new file mode 100644 index 0000000000..e430f55d63 --- /dev/null +++ b/frontend/src/container/FormAlertRules/useDebounce.js @@ -0,0 +1,31 @@ +/* eslint-disable */ +// @ts-ignore +// @ts-nocheck + +import { useEffect, useState } from 'react'; + +// see https://github.com/tannerlinsley/react-query/issues/293 +// see https://usehooks.com/useDebounce/ +export default function useDebounce(value, delay) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect( + () => { + // Update debounced value after delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // Cancel the timeout if value changes (also on delay change or unmount) + // This is how we prevent debounced value from updating if value is changed ... + // .. within the delay period. Timeout gets cleared and restarted. + return () => { + clearTimeout(handler); + }; + }, + [value, delay] // Only re-call effect if value or delay changes + ); + + return debouncedValue; +} diff --git a/frontend/src/container/FormAlertRules/utils.ts b/frontend/src/container/FormAlertRules/utils.ts new file mode 100644 index 0000000000..c6a93d28bc --- /dev/null +++ b/frontend/src/container/FormAlertRules/utils.ts @@ -0,0 +1,134 @@ +import { Time } from 'container/TopNav/DateTimeSelection/config'; +import { + IBuilderQueries, + IFormulaQueries, + IFormulaQuery, + IMetricQueries, + IMetricQuery, + IPromQueries, + IPromQuery, +} from 'types/api/alerts/compositeQuery'; +import { + IMetricsBuilderQuery, + Query as IStagedQuery, +} from 'types/api/dashboard/getAll'; +import { EQueryType } from 'types/common/dashboard'; + +export const toFormulaQueries = (b: IBuilderQueries): IFormulaQueries => { + const f: IFormulaQueries = {}; + if (!b) return f; + Object.keys(b).forEach((key) => { + if (key === 'F1') { + f[key] = b[key] as IFormulaQuery; + } + }); + + return f; +}; + +export const toMetricQueries = (b: IBuilderQueries): IMetricQueries => { + const m: IMetricQueries = {}; + if (!b) return m; + Object.keys(b).forEach((key) => { + if (key !== 'F1') { + m[key] = b[key] as IMetricQuery; + } + }); + + return m; +}; + +export const toIMetricsBuilderQuery = ( + q: IMetricQuery, +): IMetricsBuilderQuery => { + return { + name: q.name, + metricName: q.metricName, + tagFilters: q.tagFilters, + groupBy: q.groupBy, + aggregateOperator: q.aggregateOperator, + disabled: q.disabled, + legend: q.legend, + }; +}; + +export const prepareBuilderQueries = ( + m: IMetricQueries, + f: IFormulaQueries, +): IBuilderQueries => { + if (!m) return {}; + const b: IBuilderQueries = { + ...m, + }; + + Object.keys(f).forEach((key) => { + b[key] = { + ...f[key], + aggregateOperator: undefined, + metricName: '', + }; + }); + return b; +}; + +export const prepareStagedQuery = ( + t: EQueryType, + m: IMetricQueries, + f: IFormulaQueries, + p: IPromQueries, +): IStagedQuery => { + const qbList: IMetricQuery[] = []; + const formulaList: IFormulaQuery[] = []; + const promList: IPromQuery[] = []; + + // convert map[string]IMetricQuery to IMetricQuery[] + if (m) { + Object.keys(m).forEach((key) => { + qbList.push(m[key]); + }); + } + + // convert map[string]IFormulaQuery to IFormulaQuery[] + if (f) { + Object.keys(f).forEach((key) => { + formulaList.push(f[key]); + }); + } + + // convert map[string]IPromQuery to IPromQuery[] + if (p) { + Object.keys(p).forEach((key) => { + promList.push({ ...p[key], name: key }); + }); + } + + return { + queryType: t, + promQL: promList, + metricsBuilder: { + formulas: formulaList, + queryBuilder: qbList, + }, + clickHouse: [], + }; +}; + +// toChartInterval converts eval window to chart selection time interval +export const toChartInterval = (evalWindow: string | undefined): Time => { + switch (evalWindow) { + case '5m0s': + return '5min'; + case '10m0s': + return '10min'; + case '15m0s': + return '15min'; + case '30m0s': + return '30min'; + case '60m0s': + return '30min'; + case '1440m0s': + return '1day'; + default: + return '5min'; + } +}; diff --git a/frontend/src/container/GridGraphComponent/index.tsx b/frontend/src/container/GridGraphComponent/index.tsx index d2139b1a08..3a1b84e963 100644 --- a/frontend/src/container/GridGraphComponent/index.tsx +++ b/frontend/src/container/GridGraphComponent/index.tsx @@ -1,6 +1,6 @@ import { Typography } from 'antd'; import { ChartData } from 'chart.js'; -import Graph, { GraphOnClickHandler } from 'components/Graph'; +import Graph, { GraphOnClickHandler, StaticLineProps } from 'components/Graph'; import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; import ValueGraph from 'components/ValueGraph'; import { GRAPH_TYPES } from 'container/NewDashboard/ComponentsSlider'; @@ -18,6 +18,7 @@ function GridGraphComponent({ onClickHandler, name, yAxisUnit, + staticLine, }: GridGraphComponentProps): JSX.Element | null { const location = history.location.pathname; @@ -36,6 +37,7 @@ function GridGraphComponent({ onClickHandler, name, yAxisUnit, + staticLine, }} /> ); @@ -82,6 +84,7 @@ export interface GridGraphComponentProps { onClickHandler?: GraphOnClickHandler; name: string; yAxisUnit?: string; + staticLine?: StaticLineProps; } GridGraphComponent.defaultProps = { @@ -90,6 +93,7 @@ GridGraphComponent.defaultProps = { isStacked: undefined, onClickHandler: undefined, yAxisUnit: undefined, + staticLine: undefined, }; export default GridGraphComponent; diff --git a/frontend/src/container/ListAlertRules/ListAlert.tsx b/frontend/src/container/ListAlertRules/ListAlert.tsx index b851b0829a..4df6290725 100644 --- a/frontend/src/container/ListAlertRules/ListAlert.tsx +++ b/frontend/src/container/ListAlertRules/ListAlert.tsx @@ -64,9 +64,14 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { }, { title: 'Alert Name', - dataIndex: 'name', + dataIndex: 'alert', key: 'name', sorter: (a, b): number => a.name.charCodeAt(0) - b.name.charCodeAt(0), + render: (value, record): JSX.Element => ( + onEditHandler(record.id.toString())}> + {value} + + ), }, { title: 'Severity', @@ -83,7 +88,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { }, }, { - title: 'Tags', + title: 'Labels', dataIndex: 'labels', key: 'tags', align: 'center', @@ -100,7 +105,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { {withOutSeverityKeys.map((e) => { return ( - {e} + {e}: {value[e]} ); })} diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/index.tsx b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/index.tsx index 4dee33c779..55adbd740b 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/index.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/index.tsx @@ -29,7 +29,7 @@ function PromQLQueryContainer({ toggleDelete, }: IPromQLQueryHandleChange): void => { const allQueries = queryData[WIDGET_PROMQL_QUERY_KEY_NAME]; - const currentIndexQuery = allQueries[queryIndex]; + const currentIndexQuery = allQueries[queryIndex as number]; if (query !== undefined) currentIndexQuery.query = query; if (legend !== undefined) currentIndexQuery.legend = legend; @@ -37,7 +37,7 @@ function PromQLQueryContainer({ currentIndexQuery.disabled = !currentIndexQuery.disabled; } if (toggleDelete) { - allQueries.splice(queryIndex, 1); + allQueries.splice(queryIndex as number, 1); } updateQueryData({ updatedQuery: { ...queryData } }); }; diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/query.tsx b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/query.tsx index 1a6dd2f9d2..6cffd55d8d 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/query.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/query.tsx @@ -7,7 +7,7 @@ import { IPromQLQueryHandleChange } from './types'; interface IPromQLQueryBuilderProps { queryData: IPromQLQuery; - queryIndex: number; + queryIndex: number | string; handleQueryChange: (args: IPromQLQueryHandleChange) => void; } diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/types.ts b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/types.ts index f1c88dd488..668a0c1f87 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/types.ts +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL/types.ts @@ -1,7 +1,7 @@ import { IPromQLQuery } from 'types/api/dashboard/getAll'; export interface IPromQLQueryHandleChange { - queryIndex: number; + queryIndex: number | string; query?: IPromQLQuery['query']; legend?: IPromQLQuery['legend']; toggleDisable?: IPromQLQuery['disabled']; diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/formula.tsx b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/formula.tsx index 5be08f044e..02bc41198c 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/formula.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/formula.tsx @@ -9,7 +9,7 @@ const { TextArea } = Input; interface IMetricsBuilderFormulaProps { formulaData: IMetricsBuilderFormula; - formulaIndex: number; + formulaIndex: number | string; handleFormulaChange: (args: IQueryBuilderFormulaHandleChange) => void; } function MetricsBuilderFormula({ diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/index.tsx b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/index.tsx index 5b05eeca91..fdb6d4b7bc 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/index.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/index.tsx @@ -50,7 +50,7 @@ function QueryBuilderQueryContainer({ }: IQueryBuilderQueryHandleChange): void => { const allQueries = queryData[WIDGET_QUERY_BUILDER_QUERY_KEY_NAME].queryBuilder; - const currentIndexQuery = allQueries[queryIndex]; + const currentIndexQuery = allQueries[queryIndex as number]; if (aggregateFunction) { currentIndexQuery.aggregateOperator = aggregateFunction; } @@ -78,7 +78,7 @@ function QueryBuilderQueryContainer({ currentIndexQuery.disabled = !currentIndexQuery.disabled; } if (toggleDelete) { - allQueries.splice(queryIndex, 1); + allQueries.splice(queryIndex as number, 1); } updateQueryData({ updatedQuery: { ...queryData } }); }; @@ -92,7 +92,7 @@ function QueryBuilderQueryContainer({ queryData[WIDGET_QUERY_BUILDER_QUERY_KEY_NAME][ WIDGET_QUERY_BUILDER_FORMULA_KEY_NAME ]; - const currentIndexFormula = allFormulas[formulaIndex]; + const currentIndexFormula = allFormulas[formulaIndex as number]; if (expression) { currentIndexFormula.expression = expression; @@ -103,7 +103,7 @@ function QueryBuilderQueryContainer({ } if (toggleDelete) { - allFormulas.splice(formulaIndex, 1); + allFormulas.splice(formulaIndex as number, 1); } updateQueryData({ updatedQuery: { ...queryData } }); }; diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/query.tsx b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/query.tsx index fccf108b41..8f171baa3c 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/query.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/query.tsx @@ -15,7 +15,7 @@ import { IQueryBuilderQueryHandleChange } from './types'; const { Option } = Select; interface IMetricsBuilderProps { - queryIndex: number; + queryIndex: number | string; selectedGraph: GRAPH_TYPES; queryData: IMetricsBuilderQuery; handleQueryChange: (args: IQueryBuilderQueryHandleChange) => void; diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/types.ts b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/types.ts index 8d177cffd8..c577b8d123 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/types.ts +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/queryBuilder/types.ts @@ -4,7 +4,7 @@ import { } from 'types/api/dashboard/getAll'; export interface IQueryBuilderQueryHandleChange { - queryIndex: number; + queryIndex: number | string; aggregateFunction?: IMetricsBuilderQuery['aggregateOperator']; metricName?: IMetricsBuilderQuery['metricName']; tagFilters?: IMetricsBuilderQuery['tagFilters']['items']; @@ -16,7 +16,7 @@ export interface IQueryBuilderQueryHandleChange { } export interface IQueryBuilderFormulaHandleChange { - formulaIndex: number; + formulaIndex: number | string; expression?: IMetricsBuilderFormula['expression']; toggleDisable?: IMetricsBuilderFormula['disabled']; toggleDelete?: boolean; diff --git a/frontend/src/container/TopNav/DateTimeSelection/config.ts b/frontend/src/container/TopNav/DateTimeSelection/config.ts index 29d031e25b..427cb8786e 100644 --- a/frontend/src/container/TopNav/DateTimeSelection/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelection/config.ts @@ -1,6 +1,7 @@ import ROUTES from 'constants/routes'; type FiveMin = '5min'; +type TenMin = '10min'; type FifteenMin = '15min'; type ThirtyMin = '30min'; type OneMin = '1min'; @@ -12,6 +13,7 @@ type Custom = 'custom'; export type Time = | FiveMin + | TenMin | FifteenMin | ThirtyMin | OneMin diff --git a/frontend/src/lib/getMinMax.ts b/frontend/src/lib/getMinMax.ts index 9c1fab94c3..cd6f26a496 100644 --- a/frontend/src/lib/getMinMax.ts +++ b/frontend/src/lib/getMinMax.ts @@ -13,6 +13,9 @@ const GetMinMax = ( if (interval === '1min') { const minTimeAgo = getMinAgo({ minutes: 1 }).getTime(); minTime = minTimeAgo; + } else if (interval === '10min') { + const minTimeAgo = getMinAgo({ minutes: 10 }).getTime(); + minTime = minTimeAgo; } else if (interval === '15min') { const minTimeAgo = getMinAgo({ minutes: 15 }).getTime(); minTime = minTimeAgo; diff --git a/frontend/src/pages/CreateAlert/index.tsx b/frontend/src/pages/CreateAlert/index.tsx index edfe543b1f..3bab0c1ee7 100644 --- a/frontend/src/pages/CreateAlert/index.tsx +++ b/frontend/src/pages/CreateAlert/index.tsx @@ -1,109 +1,9 @@ -import { SaveOutlined } from '@ant-design/icons'; -import { Button, notification } from 'antd'; -import createAlertsApi from 'api/alerts/create'; -import Editor from 'components/Editor'; -import ROUTES from 'constants/routes'; -import { State } from 'hooks/useFetch'; -import history from 'lib/history'; -import React, { useCallback, useState } from 'react'; -import { PayloadProps as CreateAlertPayloadProps } from 'types/api/alerts/create'; +import CreateAlertRule from 'container/CreateAlertRule'; +import React from 'react'; +import { alertDefaults } from 'types/api/alerts/create'; -import { ButtonContainer, Title } from './styles'; - -function CreateAlert(): JSX.Element { - const [value, setEditorValue] = useState( - `\n alert: High RPS\n expr: sum(rate(signoz_latency_count{span_kind="SPAN_KIND_SERVER"}[2m])) by (service_name) > 100\n for: 0m\n labels:\n severity: warning\n annotations:\n summary: High RPS of Applications\n description: "RPS is > 100\n\t\t\t VALUE = {{ $value }}\n\t\t\t LABELS = {{ $labels }}"\n `, - ); - - const [newAlertState, setNewAlertState] = useState< - State - >({ - error: false, - errorMessage: '', - loading: false, - payload: undefined, - success: false, - }); - const [notifications, Element] = notification.useNotification(); - - const defaultError = - 'Oops! Some issue occured in saving the alert please try again or contact support@signoz.io'; - - const onSaveHandler = useCallback(async () => { - try { - setNewAlertState((state) => ({ - ...state, - loading: true, - })); - - if (value.length === 0) { - setNewAlertState((state) => ({ - ...state, - loading: false, - })); - notifications.error({ - description: `Oops! We didn't catch that. Please make sure the alert settings are not empty or try again`, - message: 'Error', - }); - return; - } - - const response = await createAlertsApi({ - query: value, - }); - - if (response.statusCode === 200) { - setNewAlertState((state) => ({ - ...state, - loading: false, - payload: response.payload, - })); - notifications.success({ - message: 'Success', - description: 'Congrats. The alert was saved correctly.', - }); - - setTimeout(() => { - history.push(ROUTES.LIST_ALL_ALERT); - }, 3000); - } else { - notifications.error({ - description: response.error || defaultError, - message: 'Error', - }); - setNewAlertState((state) => ({ - ...state, - loading: false, - error: true, - errorMessage: response.error || defaultError, - })); - } - } catch (error) { - notifications.error({ - message: defaultError, - }); - } - }, [notifications, value]); - - return ( - <> - {Element} - - Create New Alert - setEditorValue(value)} value={value} /> - - - - - - ); +function CreateAlertPage(): JSX.Element { + return ; } -export default CreateAlert; +export default CreateAlertPage; diff --git a/frontend/src/pages/EditRules/index.tsx b/frontend/src/pages/EditRules/index.tsx index 09cda600ab..0217e40efc 100644 --- a/frontend/src/pages/EditRules/index.tsx +++ b/frontend/src/pages/EditRules/index.tsx @@ -47,7 +47,12 @@ function EditRules(): JSX.Element { return ; } - return ; + return ( + + ); } export default EditRules; diff --git a/frontend/src/types/api/alerts/compositeQuery.ts b/frontend/src/types/api/alerts/compositeQuery.ts new file mode 100644 index 0000000000..868eb712c4 --- /dev/null +++ b/frontend/src/types/api/alerts/compositeQuery.ts @@ -0,0 +1,64 @@ +import { + IMetricsBuilderFormula, + IMetricsBuilderQuery, + IPromQLQuery, + IQueryBuilderTagFilters, +} from 'types/api/dashboard/getAll'; +import { EAggregateOperator, EQueryType } from 'types/common/dashboard'; + +export interface ICompositeMetricQuery { + builderQueries: IBuilderQueries; + promQueries: IPromQueries; + queryType: EQueryType; +} + +export interface IPromQueries { + [key: string]: IPromQuery; +} + +export interface IPromQuery extends IPromQLQuery { + stats?: ''; +} + +export interface IBuilderQueries { + [key: string]: IBuilderQuery; +} + +// IBuilderQuery combines IMetricQuery and IFormulaQuery +// for api calls +export interface IBuilderQuery + extends Omit< + IMetricQuery, + 'aggregateOperator' | 'legend' | 'metricName' | 'tagFilters' + >, + Omit { + aggregateOperator: EAggregateOperator | undefined; + disabled: boolean; + name: string; + legend?: string; + metricName: string | null; + groupBy?: string[]; + expression?: string; + tagFilters?: IQueryBuilderTagFilters; + toggleDisable?: boolean; + toggleDelete?: boolean; +} + +export interface IFormulaQueries { + [key: string]: IFormulaQuery; +} + +export interface IFormulaQuery extends IMetricsBuilderFormula { + formulaOnly: boolean; + queryName: string; +} + +export interface IMetricQueries { + [key: string]: IMetricQuery; +} + +export interface IMetricQuery extends IMetricsBuilderQuery { + formulaOnly: boolean; + expression?: string; + queryName: string; +} diff --git a/frontend/src/types/api/alerts/create.ts b/frontend/src/types/api/alerts/create.ts index 6a2e5c09ab..6f179af79a 100644 --- a/frontend/src/types/api/alerts/create.ts +++ b/frontend/src/types/api/alerts/create.ts @@ -1,8 +1,48 @@ +import { AlertDef } from 'types/api/alerts/def'; + +import { defaultCompareOp, defaultEvalWindow, defaultMatchType } from './def'; + export interface Props { - query: string; + data: AlertDef; } export interface PayloadProps { status: string; data: string; } + +export const alertDefaults: AlertDef = { + condition: { + compositeMetricQuery: { + builderQueries: { + A: { + queryName: 'A', + name: 'A', + formulaOnly: false, + metricName: '', + tagFilters: { + op: 'AND', + items: [], + }, + groupBy: [], + aggregateOperator: 1, + expression: 'A', + disabled: false, + toggleDisable: false, + toggleDelete: false, + }, + }, + promQueries: {}, + queryType: 1, + }, + op: defaultCompareOp, + matchType: defaultMatchType, + }, + labels: { + severity: 'warning', + }, + annotations: { + description: 'A new alert', + }, + evalWindow: defaultEvalWindow, +}; diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts new file mode 100644 index 0000000000..060bdc4d73 --- /dev/null +++ b/frontend/src/types/api/alerts/def.ts @@ -0,0 +1,32 @@ +import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery'; + +// default match type for threshold +export const defaultMatchType = '1'; + +// default eval window +export const defaultEvalWindow = '5m0s'; + +// default compare op: above +export const defaultCompareOp = '1'; + +export interface AlertDef { + id?: number; + alert?: string; + ruleType?: string; + condition: RuleCondition; + labels?: Labels; + annotations?: Labels; + evalWindow?: string; + source?: string; +} + +export interface RuleCondition { + compositeMetricQuery: ICompositeMetricQuery; + op?: string | undefined; + target?: number | undefined; + matchType?: string | undefined; +} + +export interface Labels { + [key: string]: string; +} diff --git a/frontend/src/types/api/alerts/get.ts b/frontend/src/types/api/alerts/get.ts index 52e9a78e7b..69eef474e1 100644 --- a/frontend/src/types/api/alerts/get.ts +++ b/frontend/src/types/api/alerts/get.ts @@ -1,9 +1,9 @@ -import { Alerts } from './getAll'; +import { AlertDef } from './def'; export interface Props { - id: Alerts['id']; + id: AlertDef['id']; } export type PayloadProps = { - data: string; + data: AlertDef; }; diff --git a/frontend/src/types/api/alerts/put.ts b/frontend/src/types/api/alerts/put.ts deleted file mode 100644 index e70de0b630..0000000000 --- a/frontend/src/types/api/alerts/put.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PayloadProps as DeletePayloadProps } from './delete'; -import { Alerts } from './getAll'; - -export type PayloadProps = DeletePayloadProps; - -export interface Props { - id: Alerts['id']; - data: DeletePayloadProps['data']; -} diff --git a/frontend/src/types/api/alerts/queryType.ts b/frontend/src/types/api/alerts/queryType.ts new file mode 100644 index 0000000000..277d6f0703 --- /dev/null +++ b/frontend/src/types/api/alerts/queryType.ts @@ -0,0 +1,17 @@ +export type QueryType = 1 | 2 | 3; + +export const QUERY_BUILDER: QueryType = 1; +export const PROMQL: QueryType = 3; + +export const resolveQueryCategoryName = (s: number): string => { + switch (s) { + case 1: + return 'Query Builder'; + case 2: + return 'Clickhouse Query'; + case 3: + return 'PromQL'; + default: + return ''; + } +}; diff --git a/frontend/src/types/api/alerts/save.ts b/frontend/src/types/api/alerts/save.ts new file mode 100644 index 0000000000..a815c728d2 --- /dev/null +++ b/frontend/src/types/api/alerts/save.ts @@ -0,0 +1,11 @@ +import { AlertDef } from './def'; + +export type PayloadProps = { + status: string; + data: string; +}; + +export interface Props { + id?: number; + data: AlertDef; +}