From a56293142a1da8b7ff69ac76dca743b596379ee5 Mon Sep 17 00:00:00 2001 From: Domas Date: Wed, 7 Apr 2021 08:42:43 +0300 Subject: [PATCH] Alerting: unified alerting frontend (#32708) --- .github/CODEOWNERS | 1 + go.mod | 2 +- go.sum | 2 + package.json | 7 +- .../src/components/Collapse/Collapse.tsx | 14 +- pkg/api/index.go | 11 +- pkg/services/ngalert/api/lotex_am.go | 13 +- .../test-data/am-alertmanager-recipient.http | 167 ++++++++++ pkg/services/ngalert/api/util.go | 17 +- public/app/core/components/Page/Page.tsx | 9 +- .../app/core/components/Page/PageContents.tsx | 6 +- .../core/components/PageHeader/PageHeader.tsx | 10 +- public/app/core/hooks/useQueryParams.ts | 11 + .../features/alerting/AlertRuleListIndex.tsx | 7 + .../app/features/alerting/state/reducers.ts | 2 + .../features/alerting/unified/AmRoutes.tsx | 39 +++ .../alerting/unified/RuleList.test.tsx | 284 ++++++++++++++++++ .../features/alerting/unified/RuleList.tsx | 142 +++++++++ .../alerting/unified/api/alertmanager.ts | 39 +++ .../alerting/unified/api/prometheus.ts | 29 ++ .../features/alerting/unified/api/ruler.ts | 81 +++++ .../unified/components/AlertLabel.tsx | 27 ++ .../unified/components/AlertLabels.tsx | 31 ++ .../unified/components/AlertManagerPicker.tsx | 46 +++ .../components/AlertingPageWrapper.tsx | 23 ++ .../unified/components/Annotation.tsx | 36 +++ .../unified/components/CollapseToggle.tsx | 33 ++ .../alerting/unified/components/RuleQuery.tsx | 55 ++++ .../unified/components/StateColoredText.tsx | 27 ++ .../alerting/unified/components/StateTag.tsx | 39 +++ .../alerting/unified/components/TimeToNow.tsx | 15 + .../alerting/unified/components/Well.tsx | 20 ++ .../rule-editor/AlertConditionsSection.tsx | 53 ++++ .../components/rule-editor/AlertDetails.tsx | 17 ++ .../components/rule-editor/AlertRuleForm.tsx | 129 ++++++++ .../rule-editor/AlertTypeSection.tsx | 149 +++++++++ .../rule-editor/AnnotationsField.tsx | 116 +++++++ .../components/rule-editor/Expression.tsx | 17 ++ .../components/rule-editor/LabelsField.tsx | 118 ++++++++ .../unified/components/rules/ActionButton.tsx | 16 + .../unified/components/rules/ActionIcon.tsx | 38 +++ .../components/rules/AlertInstanceDetails.tsx | 25 ++ .../components/rules/AlertInstancesTable.tsx | 103 +++++++ .../unified/components/rules/DetailsField.tsx | 46 +++ .../unified/components/rules/NoRulesCTA.tsx | 15 + .../unified/components/rules/RuleDetails.tsx | 80 +++++ .../unified/components/rules/RuleTableRow.tsx | 0 .../unified/components/rules/RulesGroup.tsx | 168 +++++++++++ .../unified/components/rules/RulesTable.tsx | 246 +++++++++++++++ .../rules/SystemOrApplicationRules.tsx | 66 ++++ .../components/rules/ThresholdRules.tsx | 54 ++++ .../hooks/useAlertManagerSourceName.ts | 44 +++ .../hooks/useCombinedRuleNamespaces.ts | 130 ++++++++ .../alerting/unified/hooks/useHasRuler.ts | 10 + .../hooks/useUnifiedAlertingSelector.ts | 10 + public/app/features/alerting/unified/mocks.ts | 93 ++++++ .../alerting/unified/state/actions.ts | 73 +++++ .../alerting/unified/state/reducers.ts | 17 ++ .../features/alerting/unified/styles/table.ts | 26 ++ .../features/alerting/unified/utils/config.ts | 6 + .../alerting/unified/utils/constants.ts | 3 + .../alerting/unified/utils/datasource.ts | 67 +++++ .../features/alerting/unified/utils/misc.ts | 28 ++ .../features/alerting/unified/utils/redux.ts | 106 +++++++ .../features/alerting/unified/utils/rules.ts | 38 +++ .../app/features/plugins/built_in_plugins.ts | 3 + .../datasource/alertmanager/ConfigEditor.tsx | 18 ++ .../datasource/alertmanager/DataSource.ts | 62 ++++ .../datasource/alertmanager/img/logo.svg | 1 + .../plugins/datasource/alertmanager/module.ts | 8 + .../datasource/alertmanager/plugin.json | 63 ++++ .../plugins/datasource/alertmanager/types.ts | 143 +++++++++ public/app/routes/routes.tsx | 19 +- public/app/types/store.ts | 2 + public/app/types/unified-alerting-dto.ts | 96 ++++++ public/app/types/unified-alerting.ts | 93 ++++++ public/test/helpers/typeAsJestMock.ts | 9 + start-promtail.sh | 1 + yarn.lock | 15 + 79 files changed, 3857 insertions(+), 28 deletions(-) create mode 100644 pkg/services/ngalert/api/test-data/am-alertmanager-recipient.http create mode 100644 public/app/core/hooks/useQueryParams.ts create mode 100644 public/app/features/alerting/AlertRuleListIndex.tsx create mode 100644 public/app/features/alerting/unified/AmRoutes.tsx create mode 100644 public/app/features/alerting/unified/RuleList.test.tsx create mode 100644 public/app/features/alerting/unified/RuleList.tsx create mode 100644 public/app/features/alerting/unified/api/alertmanager.ts create mode 100644 public/app/features/alerting/unified/api/prometheus.ts create mode 100644 public/app/features/alerting/unified/api/ruler.ts create mode 100644 public/app/features/alerting/unified/components/AlertLabel.tsx create mode 100644 public/app/features/alerting/unified/components/AlertLabels.tsx create mode 100644 public/app/features/alerting/unified/components/AlertManagerPicker.tsx create mode 100644 public/app/features/alerting/unified/components/AlertingPageWrapper.tsx create mode 100644 public/app/features/alerting/unified/components/Annotation.tsx create mode 100644 public/app/features/alerting/unified/components/CollapseToggle.tsx create mode 100644 public/app/features/alerting/unified/components/RuleQuery.tsx create mode 100644 public/app/features/alerting/unified/components/StateColoredText.tsx create mode 100644 public/app/features/alerting/unified/components/StateTag.tsx create mode 100644 public/app/features/alerting/unified/components/TimeToNow.tsx create mode 100644 public/app/features/alerting/unified/components/Well.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertConditionsSection.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/Expression.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx create mode 100644 public/app/features/alerting/unified/components/rules/ActionButton.tsx create mode 100644 public/app/features/alerting/unified/components/rules/ActionIcon.tsx create mode 100644 public/app/features/alerting/unified/components/rules/AlertInstanceDetails.tsx create mode 100644 public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx create mode 100644 public/app/features/alerting/unified/components/rules/DetailsField.tsx create mode 100644 public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleDetails.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleTableRow.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RulesGroup.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RulesTable.tsx create mode 100644 public/app/features/alerting/unified/components/rules/SystemOrApplicationRules.tsx create mode 100644 public/app/features/alerting/unified/components/rules/ThresholdRules.tsx create mode 100644 public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts create mode 100644 public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts create mode 100644 public/app/features/alerting/unified/hooks/useHasRuler.ts create mode 100644 public/app/features/alerting/unified/hooks/useUnifiedAlertingSelector.ts create mode 100644 public/app/features/alerting/unified/mocks.ts create mode 100644 public/app/features/alerting/unified/state/actions.ts create mode 100644 public/app/features/alerting/unified/state/reducers.ts create mode 100644 public/app/features/alerting/unified/styles/table.ts create mode 100644 public/app/features/alerting/unified/utils/config.ts create mode 100644 public/app/features/alerting/unified/utils/constants.ts create mode 100644 public/app/features/alerting/unified/utils/datasource.ts create mode 100644 public/app/features/alerting/unified/utils/misc.ts create mode 100644 public/app/features/alerting/unified/utils/redux.ts create mode 100644 public/app/features/alerting/unified/utils/rules.ts create mode 100644 public/app/plugins/datasource/alertmanager/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/alertmanager/DataSource.ts create mode 100644 public/app/plugins/datasource/alertmanager/img/logo.svg create mode 100644 public/app/plugins/datasource/alertmanager/module.ts create mode 100644 public/app/plugins/datasource/alertmanager/plugin.json create mode 100644 public/app/plugins/datasource/alertmanager/types.ts create mode 100644 public/app/types/unified-alerting-dto.ts create mode 100644 public/app/types/unified-alerting.ts create mode 100644 public/test/helpers/typeAsJestMock.ts create mode 100755 start-promtail.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d38def72d38..844009662f5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -83,6 +83,7 @@ lerna.json @grafana/grafana-frontend-platform /public/app/plugins/datasource/cloud-monitoring @grafana/cloud-datasources /public/app/plugins/datasource/zipkin @grafana/observability-squad /public/app/plugins/datasource/tempo @grafana/observability-squad +/public/app/plugins/datasource/alertmanager @grafana/alerting-squad # Cloud middleware /grafana-mixin/ @grafana/cloud-middleware diff --git a/go.mod b/go.mod index 7a24ca7dffe..32faa6b823b 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/google/go-cmp v0.5.5 github.com/google/uuid v1.2.0 github.com/gosimple/slug v1.9.0 - github.com/grafana/alerting-api v0.0.0-20210331135037-3294563b51bb + github.com/grafana/alerting-api v0.0.0-20210405171311-97906879c771 github.com/grafana/grafana-aws-sdk v0.4.0 github.com/grafana/grafana-live-sdk v0.0.4 github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 diff --git a/go.sum b/go.sum index 330f4044de9..227a0dac6e2 100644 --- a/go.sum +++ b/go.sum @@ -803,6 +803,8 @@ github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs= github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= github.com/grafana/alerting-api v0.0.0-20210331135037-3294563b51bb h1:Hj25Whc/TRv0hSLm5VN0FJ5R4yZ6M4ycRcBgu7bsEAc= github.com/grafana/alerting-api v0.0.0-20210331135037-3294563b51bb/go.mod h1:5IppnPguSHcCbVLGCVzVjBvuQZNbYgVJ4KyXXjhCyWY= +github.com/grafana/alerting-api v0.0.0-20210405171311-97906879c771 h1:CTmKHUu2n0O9fPTSXb+s5FO8Em9Atw57Z7mvw7lt6IM= +github.com/grafana/alerting-api v0.0.0-20210405171311-97906879c771/go.mod h1:5IppnPguSHcCbVLGCVzVjBvuQZNbYgVJ4KyXXjhCyWY= github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 h1:GplhUk6Xes5JIhUUrggPcPBhOn+eT8+WsHiebvq7GgA= github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/grafana/grafana v1.9.2-0.20210308201921-4ce0a49eac03/go.mod h1:AHRRvd4utJGY25J5nW8aL7wZzn/LcJ0z2za9oOp14j4= diff --git a/package.json b/package.json index 00da66d95b7..06da12eee8c 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "@babel/plugin-proposal-optional-chaining": "7.13.12", "@babel/plugin-proposal-private-methods": "7.13.0", "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/plugin-transform-runtime": "^7.13.10", "@babel/plugin-transform-react-constant-elements": "7.13.13", + "@babel/plugin-transform-runtime": "^7.13.10", "@babel/preset-env": "7.13.12", "@babel/preset-react": "7.13.13", "@babel/preset-typescript": "7.13.0", @@ -194,6 +194,7 @@ "sinon": "8.1.1", "style-loader": "1.1.3", "terser-webpack-plugin": "2.3.7", + "testing-library-selector": "^0.1.3", "ts-jest": "26.4.4", "ts-node": "9.0.0", "tslib": "2.1.0", @@ -218,13 +219,13 @@ "@sentry/browser": "5.25.0", "@sentry/types": "5.24.2", "@sentry/utils": "5.24.2", - "react-select": "4.3.0", "@types/antlr4": "^4.7.1", "@types/braintree__sanitize-url": "4.0.0", "@types/common-tags": "^1.8.0", "@types/hoist-non-react-statics": "3.3.1", "@types/jsurl": "^1.2.28", "@types/md5": "^2.1.33", + "@types/pluralize": "^0.0.29", "@types/react-loadable": "5.5.2", "@types/react-virtualized-auto-sizer": "1.0.0", "@types/uuid": "8.3.0", @@ -269,6 +270,7 @@ "mousetrap-global-bind": "1.1.0", "nodemon": "2.0.2", "papaparse": "5.3.0", + "pluralize": "^8.0.0", "prismjs": "1.23.0", "prop-types": "15.7.2", "rc-cascader": "1.0.1", @@ -284,6 +286,7 @@ "react-redux": "7.2.0", "react-reverse-portal": "^2.0.1", "react-router-dom": "^5.2.0", + "react-select": "4.3.0", "react-sizeme": "2.6.12", "react-split-pane": "0.1.89", "react-transition-group": "4.4.1", diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index 1c7865ef3eb..9decabbf0b9 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -102,6 +102,8 @@ export interface Props { collapsible?: boolean; /** Callback for the toggle functionality */ onToggle?: (isOpen: boolean) => void; + /** Additional class name for the root element */ + className?: string; } export const ControlledCollapse: FunctionComponent = ({ isOpen, onToggle, ...otherProps }) => { @@ -120,7 +122,15 @@ export const ControlledCollapse: FunctionComponent = ({ isOpen, onToggle, ); }; -export const Collapse: FunctionComponent = ({ isOpen, label, loading, collapsible, onToggle, children }) => { +export const Collapse: FunctionComponent = ({ + isOpen, + label, + loading, + collapsible, + onToggle, + className, + children, +}) => { const theme = useContext(ThemeContext); const style = getStyles(theme); const onClickToggle = () => { @@ -129,7 +139,7 @@ export const Collapse: FunctionComponent = ({ isOpen, label, loading, col } }; - const panelClass = cx([style.collapse, 'panel-container']); + const panelClass = cx([style.collapse, 'panel-container', className]); const loaderClass = loading ? cx([style.loader, style.loaderActive]) : cx([style.loader]); const headerClass = collapsible ? cx([style.header]) : cx([style.headerCollapsed]); const headerButtonsClass = collapsible ? cx([style.headerButtons]) : cx([style.headerButtonsCollapsed]); diff --git a/pkg/api/index.go b/pkg/api/index.go index e85a928414d..2b5a32fdf60 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -192,13 +192,18 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto navTree = append(navTree, hs.getProfileNode(c)) } - if setting.AlertingEnabled && (c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR) { + if setting.AlertingEnabled { alertChildNavs := []*dtos.NavLink{ {Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul"}, - { + } + if c.OrgRole == models.ROLE_ADMIN && hs.Cfg.IsNgAlertEnabled() { + alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Routes", Id: "am-routes", Url: hs.Cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"}) + } + if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR { + alertChildNavs = append(alertChildNavs, &dtos.NavLink{ Text: "Notification channels", Id: "channels", Url: hs.Cfg.AppSubURL + "/alerting/notifications", Icon: "comment-alt-share", - }, + }) } navTree = append(navTree, &dtos.NavLink{ diff --git a/pkg/services/ngalert/api/lotex_am.go b/pkg/services/ngalert/api/lotex_am.go index d5532f9fb5e..1fc4a66bd03 100644 --- a/pkg/services/ngalert/api/lotex_am.go +++ b/pkg/services/ngalert/api/lotex_am.go @@ -13,10 +13,10 @@ import ( ) const ( - amSilencesPath = "/api/v2/silences" - amSilencePath = "/api/v2/silence/%s" - amAlertGroupsPath = "/api/v2/alerts/groups" - amAlertsPath = "/api/v2/alerts" + amSilencesPath = "/alertmanager/api/v2/silences" + amSilencePath = "/alertmanager/api/v2/silence/%s" + amAlertGroupsPath = "/alertmanager/api/v2/alerts/groups" + amAlertsPath = "/alertmanager/api/v2/alerts" amConfigPath = "/api/v1/alerts" ) @@ -44,8 +44,9 @@ func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimod URL: withPath(*ctx.Req.URL, amSilencesPath), Body: body, ContentLength: ln, + Header: map[string][]string{"Content-Type": {"application/json"}}, }, - jsonExtractor(&apimodels.GettableSilence{}), + jsonExtractor(nil), ) } @@ -83,7 +84,7 @@ func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Respo amConfigPath, ), }, - jsonExtractor(&apimodels.GettableUserConfig{}), + yamlExtractor(&apimodels.GettableUserConfig{}), ) } diff --git a/pkg/services/ngalert/api/test-data/am-alertmanager-recipient.http b/pkg/services/ngalert/api/test-data/am-alertmanager-recipient.http new file mode 100644 index 00000000000..24472f79d10 --- /dev/null +++ b/pkg/services/ngalert/api/test-data/am-alertmanager-recipient.http @@ -0,0 +1,167 @@ +@alertManagerDatasourceID = 36 + +### +# create AM configuration +POST http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/config/api/v1/alerts +content-type: application/json + +{ + "template_files": {}, + "alertmanager_config": { + "global": { + "resolve_timeout": "4m", + "http_config": { + "BasicAuth": null, + "Authorization": null, + "BearerToken": "", + "BearerTokenFile": "", + "ProxyURL": {}, + "TLSConfig": { + "CAFile": "", + "CertFile": "", + "KeyFile": "", + "ServerName": "", + "InsecureSkipVerify": false + }, + "FollowRedirects": true + }, + "smtp_from": "youraddress@example.org", + "smtp_hello": "localhost", + "smtp_smarthost": "localhost:25", + "smtp_require_tls": true, + "pagerduty_url": "https://events.pagerduty.com/v2/enqueue", + "opsgenie_api_url": "https://api.opsgenie.com/", + "wechat_api_url": "https://qyapi.weixin.qq.com/cgi-bin/", + "victorops_api_url": "https://alert.victorops.com/integrations/generic/20131114/alert/" + }, + "route": { + "receiver": "example-email" + }, + "templates": [], + "receivers": [ + { + "name": "example-email", + "email_configs": [ + { + "send_resolved": false, + "to": "youraddress@example.org", + "smarthost": "", + "html": "{{ template \"email.default.html\" . }}", + "tls_config": { + "CAFile": "", + "CertFile": "", + "KeyFile": "", + "ServerName": "", + "InsecureSkipVerify": false + } + } + ] + } + ] + } +} + +### +# get latest AM configuration +GET http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/config/api/v1/alerts +content-type: application/json + +### +# delete AM configuration +DELETE http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/config/api/v1/alerts + +### +# create AM alerts +POST http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/alerts +content-type: application/json + +[ + { + "startsAt": "2021-04-05T14:08:42.087Z", + "endsAt": "2021-04-05T14:08:42.087Z", + "annotations": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "labels": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "generatorURL": "http://localhost" + } +] + +### +# get AM alerts +GET http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/alerts + +### +# get silences - no silences +GET http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silences?Filter=foo="bar"&Filter=bar="foo" + +### +# create silence +POST http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silences +content-type: application/json + +{ + "matchers": [ + { + "name": "foo", + "value": "bar", + "isRegex": true + } + ], + "createdBy": "spapagian", + "comment": "a comment", + "startsAt": "2021-04-05T14:45:09.885Z", + "endsAt": "2021-04-05T16:45:09.885Z" +} + +### +# update silence - does not exist +POST http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silences +content-type: application/json + +{ + "id": "something", + "comment": "string", + "createdBy": "string", + "endsAt": "2023-03-31T14:17:04.419Z", + "matchers": [ + { + "isRegex": true, + "name": "string", + "value": "string" + } + ], + "startsAt": "2021-03-31T13:17:04.419Z" +} + +### +# get silences +# @name getSilences +GET http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silences + + +### +@silenceID = {{getSilences.response.body.$.[3].id}} + +### +# get silence +GET http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silence/{{silenceID}} + + +### +# get silence - unknown +GET http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silence/unknown + +### +# delete silence +DELETE http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silence/{{silenceID}} + +### +# delete silence - unknown +DELETE http://admin:admin@localhost:3000/alertmanager/{{alertManagerDatasourceID}}/api/v2/silence/unknown \ No newline at end of file diff --git a/pkg/services/ngalert/api/util.go b/pkg/services/ngalert/api/util.go index 5b3d17ac7f9..95db69f63f1 100644 --- a/pkg/services/ngalert/api/util.go +++ b/pkg/services/ngalert/api/util.go @@ -7,6 +7,7 @@ import ( "net/http" "regexp" "strconv" + "strings" "github.com/go-openapi/strfmt" apimodels "github.com/grafana/alerting-api/pkg/api" @@ -45,7 +46,7 @@ func backendType(ctx *models.ReqContext, cache datasources.CacheService) (apimod switch ds.Type { case "loki", "prometheus": return apimodels.LoTexRulerBackend, nil - case "grafana-alertmanager-datasource": + case "alertmanager": return apimodels.AlertmanagerBackend, nil default: return 0, fmt.Errorf("unexpected backend type (%v)", ds.Type) @@ -94,7 +95,19 @@ func (p *AlertingProxy) withReq( status := resp.Status() if status >= 400 { - return response.Error(status, string(resp.Body()), nil) + errMessage := string(resp.Body()) + // if Content-Type is application/json + // and it is successfully decoded and contains a message + // return this as response error message + if strings.HasPrefix(resp.Header().Get("Content-Type"), "application/json") { + var m map[string]interface{} + if err := json.Unmarshal(resp.Body(), &m); err == nil { + if message, ok := m["message"]; ok { + errMessage = message.(string) + } + } + } + return response.Error(status, errMessage, nil) } t, err := extractor(resp.Body()) diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 7c224a0d82b..eecfbd90e25 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -9,11 +9,12 @@ import { PageContents } from './PageContents'; import { CustomScrollbar, useStyles } from '@grafana/ui'; import { GrafanaTheme, NavModel } from '@grafana/data'; import { Branding } from '../Branding/Branding'; -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; interface Props extends HTMLAttributes { children: React.ReactNode; navModel: NavModel; + contentWidth?: keyof GrafanaTheme['breakpoints']; } export interface PageType extends FC { @@ -21,7 +22,7 @@ export interface PageType extends FC { Contents: typeof PageContents; } -export const Page: PageType = ({ navModel, children, ...otherProps }) => { +export const Page: PageType = ({ navModel, children, className, contentWidth, ...otherProps }) => { const styles = useStyles(getStyles); useEffect(() => { @@ -30,10 +31,10 @@ export const Page: PageType = ({ navModel, children, ...otherProps }) => { }, [navModel]); return ( -
+
- + {children}
diff --git a/public/app/core/components/Page/PageContents.tsx b/public/app/core/components/Page/PageContents.tsx index b9b4ef3156e..edfc9a019a6 100644 --- a/public/app/core/components/Page/PageContents.tsx +++ b/public/app/core/components/Page/PageContents.tsx @@ -1,5 +1,6 @@ // Libraries import React, { FC } from 'react'; +import { cx } from '@emotion/css'; // Components import PageLoader from '../PageLoader/PageLoader'; @@ -7,8 +8,9 @@ import PageLoader from '../PageLoader/PageLoader'; interface Props { isLoading?: boolean; children: React.ReactNode; + className?: string; } -export const PageContents: FC = ({ isLoading, children }) => { - return
{isLoading ? : children}
; +export const PageContents: FC = ({ isLoading, children, className }) => { + return
{isLoading ? : children}
; }; diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index bf91c5bb862..8ea37b18000 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,11 +1,12 @@ import React, { FC } from 'react'; -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { Tab, TabsBar, Icon, IconName, useStyles } from '@grafana/ui'; import { NavModel, NavModelItem, NavModelBreadcrumb, GrafanaTheme } from '@grafana/data'; import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; export interface Props { model: NavModel; + contentWidth?: keyof GrafanaTheme['breakpoints']; } const SelectNav = ({ children, customCss }: { children: NavModelItem[]; customCss: string }) => { @@ -71,7 +72,7 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => { ); }; -export const PageHeader: FC = ({ model }) => { +export const PageHeader: FC = ({ model, contentWidth }) => { const styles = useStyles(getStyles); if (!model) { @@ -83,7 +84,7 @@ export const PageHeader: FC = ({ model }) => { return (
-
+
{renderHeaderTitle(main)} {children && children.length && {children}} @@ -142,6 +143,9 @@ const getStyles = (theme: GrafanaTheme) => ({ background: ${theme.colors.bg2}; border-bottom: 1px solid ${theme.colors.border1}; `, + contentWidth: (size: keyof GrafanaTheme['breakpoints']) => css` + max-width: ${theme.breakpoints[size]}; + `, }); export default PageHeader; diff --git a/public/app/core/hooks/useQueryParams.ts b/public/app/core/hooks/useQueryParams.ts new file mode 100644 index 00000000000..71815f44180 --- /dev/null +++ b/public/app/core/hooks/useQueryParams.ts @@ -0,0 +1,11 @@ +import { UrlQueryMap } from '@grafana/data'; +import { locationSearchToObject, locationService } from '@grafana/runtime'; +import { useCallback, useMemo } from 'react'; +import { useLocation } from 'react-use'; + +export function useQueryParams(): [UrlQueryMap, (values: UrlQueryMap, replace?: boolean) => void] { + const { search } = useLocation(); + const queryParams = useMemo(() => locationSearchToObject(search || ''), [search]); + const update = useCallback((values: UrlQueryMap, replace?: boolean) => locationService.partial(values, replace), []); + return [queryParams, update]; +} diff --git a/public/app/features/alerting/AlertRuleListIndex.tsx b/public/app/features/alerting/AlertRuleListIndex.tsx new file mode 100644 index 00000000000..e1fe0587a7b --- /dev/null +++ b/public/app/features/alerting/AlertRuleListIndex.tsx @@ -0,0 +1,7 @@ +import { config } from '@grafana/runtime'; +import { RuleList } from './unified/RuleList'; +import AlertRuleList from './AlertRuleList'; + +// route between unified and "old" alerting pages based on feature flag + +export default config.featureToggles.ngalert ? RuleList : AlertRuleList; diff --git a/public/app/features/alerting/state/reducers.ts b/public/app/features/alerting/state/reducers.ts index 36ca8e90324..24083dfe2b8 100644 --- a/public/app/features/alerting/state/reducers.ts +++ b/public/app/features/alerting/state/reducers.ts @@ -18,6 +18,7 @@ import { import store from 'app/core/store'; import { config } from '@grafana/runtime'; import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; +import unifiedAlertingReducer from '../unified/state/reducers'; export const ALERT_DEFINITION_UI_STATE_STORAGE_KEY = 'grafana.alerting.alertDefinition.ui'; const DEFAULT_ALERT_DEFINITION_UI_STATE: AlertDefinitionUiState = { rightPaneSize: 400, topPaneSize: 0.45 }; @@ -236,6 +237,7 @@ export default { alertRules: alertRulesReducer, notificationChannel: notificationChannelReducer, alertDefinition: alertDefinitionsReducer, + unifiedAlerting: unifiedAlertingReducer, }; function migrateSecureFields( diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx new file mode 100644 index 00000000000..b1bfb6895d3 --- /dev/null +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -0,0 +1,39 @@ +import { InfoBox, LoadingPlaceholder } from '@grafana/ui'; +import React, { FC, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { fetchAlertManagerConfigAction } from './state/actions'; +import { initialAsyncRequestState } from './utils/redux'; + +const AmRoutes: FC = () => { + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const dispatch = useDispatch(); + + const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); + + useEffect(() => { + dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); + }, [alertManagerSourceName, dispatch]); + + const { result, loading, error } = amConfigs[alertManagerSourceName] || initialAsyncRequestState; + + return ( + + +
+
+ {error && !loading && ( + Error loading alert manager config}> + {error.message || 'Unknown error.'} + + )} + {loading && } + {result && !loading && !error &&
{JSON.stringify(result, null, 2)}
} +
+ ); +}; + +export default AmRoutes; diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx new file mode 100644 index 00000000000..4d9f0f7b9a0 --- /dev/null +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { RuleList } from './RuleList'; +import { byTestId, byText } from 'testing-library-selector'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { getAllDataSources } from './utils/config'; +import { fetchRules } from './api/prometheus'; +import { + mockDataSource, + mockPromAlert, + mockPromAlertingRule, + mockPromRecordingRule, + mockPromRuleGroup, + mockPromRuleNamespace, +} from './mocks'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { SerializedError } from '@reduxjs/toolkit'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import userEvent from '@testing-library/user-event'; + +jest.mock('./api/prometheus'); +jest.mock('./utils/config'); + +const mocks = { + getAllDataSourcesMock: typeAsJestMock(getAllDataSources), + + api: { + fetchRules: typeAsJestMock(fetchRules), + }, +}; + +const renderRuleList = () => { + const store = configureStore(); + + return render( + + + + ); +}; + +const dataSources = { + prom: mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, + }), + loki: mockDataSource({ + name: 'Loki', + type: DataSourceType.Loki, + }), + promBroken: mockDataSource({ + name: 'Prometheus-broken', + type: DataSourceType.Prometheus, + }), +}; + +const ui = { + ruleGroup: byTestId('rule-group'), + cloudRulesSourceErrors: byTestId('cloud-rulessource-errors'), + groupCollapseToggle: byTestId('group-collapse-toggle'), + ruleCollapseToggle: byTestId('rule-collapse-toggle'), + alertCollapseToggle: byTestId('alert-collapse-toggle'), + rulesTable: byTestId('rules-table'), +}; + +describe('RuleList', () => { + afterEach(() => jest.resetAllMocks()); + + it('load & show rule groups from multiple cloud data sources', async () => { + mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources)); + + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { + if (dataSourceName === dataSources.prom.name) { + return Promise.resolve([ + mockPromRuleNamespace({ + name: 'default', + dataSourceName: dataSources.prom.name, + groups: [ + mockPromRuleGroup({ + name: 'group-2', + }), + mockPromRuleGroup({ + name: 'group-1', + }), + ], + }), + ]); + } else if (dataSourceName === dataSources.loki.name) { + return Promise.resolve([ + mockPromRuleNamespace({ + name: 'default', + dataSourceName: dataSources.loki.name, + groups: [ + mockPromRuleGroup({ + name: 'group-1', + }), + ], + }), + mockPromRuleNamespace({ + name: 'lokins', + dataSourceName: dataSources.loki.name, + groups: [ + mockPromRuleGroup({ + name: 'group-1', + }), + ], + }), + ]); + } else if (dataSourceName === dataSources.promBroken.name) { + return Promise.reject({ message: 'this datasource is broken' } as SerializedError); + } else if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { + return Promise.resolve([ + mockPromRuleNamespace({ + name: '', + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + groups: [ + mockPromRuleGroup({ + name: 'grafana-group', + }), + ], + }), + ]); + } + return Promise.reject(new Error(`unexpected datasourceName: ${dataSourceName}`)); + }); + + await renderRuleList(); + + await waitFor(() => expect(mocks.api.fetchRules).toHaveBeenCalledTimes(4)); + const groups = await ui.ruleGroup.findAll(); + expect(groups).toHaveLength(5); + + expect(groups[0]).toHaveTextContent('grafana-group'); + expect(groups[1]).toHaveTextContent('default > group-1'); + expect(groups[2]).toHaveTextContent('default > group-1'); + expect(groups[3]).toHaveTextContent('default > group-2'); + expect(groups[4]).toHaveTextContent('lokins > group-1'); + + const errors = await ui.cloudRulesSourceErrors.find(); + + expect(errors).toHaveTextContent('Failed to load rules state from Prometheus-broken: this datasource is broken'); + }); + + it('expand rule group, rule and alert details', async () => { + mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { + if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { + return Promise.resolve([]); + } else { + return Promise.resolve([ + mockPromRuleNamespace({ + groups: [ + mockPromRuleGroup({ + name: 'group-1', + }), + mockPromRuleGroup({ + name: 'group-2', + rules: [ + mockPromRecordingRule({ + name: 'recordingrule', + }), + mockPromAlertingRule({ + name: 'alertingrule', + labels: { + severity: 'warning', + foo: 'bar', + }, + query: 'topk(5, foo)[5m]', + annotations: { + message: 'great alert', + }, + alerts: [ + mockPromAlert({ + labels: { + foo: 'bar', + severity: 'warning', + }, + value: '2e+10', + annotations: { + message: 'first alert message', + }, + }), + mockPromAlert({ + labels: { + foo: 'baz', + severity: 'error', + }, + value: '3e+11', + annotations: { + message: 'first alert message', + }, + }), + ], + }), + mockPromAlertingRule({ + name: 'p-rule', + alerts: [], + state: PromAlertingRuleState.Pending, + }), + mockPromAlertingRule({ + name: 'i-rule', + alerts: [], + state: PromAlertingRuleState.Inactive, + }), + ], + }), + ], + }), + ]); + } + }); + + await renderRuleList(); + + const groups = await ui.ruleGroup.findAll(); + expect(groups).toHaveLength(2); + expect(groups[0]).toHaveTextContent('1 rule'); + expect(groups[1]).toHaveTextContent('4 rules: 1 firing, 1 pending'); + + // expand second group to see rules table + expect(ui.rulesTable.query()).not.toBeInTheDocument(); + userEvent.click(ui.groupCollapseToggle.get(groups[1])); + const table = await ui.rulesTable.find(groups[1]); + + // check that rule rows are rendered properly + let ruleRows = table.querySelectorAll(':scope > tbody > tr'); + expect(ruleRows).toHaveLength(4); + + expect(ruleRows[0]).toHaveTextContent('n/a'); + expect(ruleRows[0]).toHaveTextContent('recordingrule'); + + expect(ruleRows[1]).toHaveTextContent('firing'); + expect(ruleRows[1]).toHaveTextContent('alertingrule'); + + expect(ruleRows[2]).toHaveTextContent('pending'); + expect(ruleRows[2]).toHaveTextContent('p-rule'); + + expect(ruleRows[3]).toHaveTextContent('inactive'); + expect(ruleRows[3]).toHaveTextContent('i-rule'); + + expect(byText('Labels').query()).not.toBeInTheDocument(); + + // expand alert details + userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1])); + + ruleRows = table.querySelectorAll(':scope > tbody > tr'); + expect(ruleRows).toHaveLength(5); + + const ruleDetails = ruleRows[2]; + + expect(ruleDetails).toHaveTextContent('Labelsseverity=warningfoo=bar'); + expect(ruleDetails).toHaveTextContent('Expressiontopk ( 5 , foo ) [ 5m ]'); + expect(ruleDetails).toHaveTextContent('messagegreat alert'); + expect(ruleDetails).toHaveTextContent('Matching instances'); + + // finally, check instances table + const instancesTable = ruleDetails.querySelector('table'); + expect(instancesTable).toBeInTheDocument(); + let instanceRows = instancesTable?.querySelectorAll(':scope > tbody > tr'); + expect(instanceRows).toHaveLength(2); + + expect(instanceRows![0]).toHaveTextContent('firingfoo=barseverity=warning2021-03-18 13:47:05'); + expect(instanceRows![1]).toHaveTextContent('firingfoo=bazseverity=error2021-03-18 13:47:05'); + + // expand details of an instance + userEvent.click(ui.alertCollapseToggle.get(instanceRows![0])); + instanceRows = instancesTable?.querySelectorAll(':scope > tbody > tr')!; + expect(instanceRows).toHaveLength(3); + + const alertDetails = instanceRows[1]; + expect(alertDetails).toHaveTextContent('Value2e+10'); + expect(alertDetails).toHaveTextContent('messagefirst alert message'); + + // collapse everything again + userEvent.click(ui.alertCollapseToggle.get(instanceRows![0])); + expect(instancesTable?.querySelectorAll(':scope > tbody > tr')).toHaveLength(2); + userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1])); + expect(table.querySelectorAll(':scope > tbody > tr')).toHaveLength(4); + userEvent.click(ui.groupCollapseToggle.get(groups[1])); + expect(ui.rulesTable.query()).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx new file mode 100644 index 00000000000..9fed789db0c --- /dev/null +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -0,0 +1,142 @@ +import { DataSourceInstanceSettings, GrafanaTheme } from '@grafana/data'; +import { Icon, InfoBox, useStyles, Button } from '@grafana/ui'; +import { SerializedError } from '@reduxjs/toolkit'; +import React, { FC, useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +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 { + getAllRulesSourceNames, + getRulesDataSources, + GRAFANA_RULES_SOURCE_NAME, + isCloudRulesSource, +} from './utils/datasource'; +import { css } from '@emotion/css'; +import { ThresholdRules } from './components/rules/ThresholdRules'; +import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; +import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants'; +import { isRulerNotSupportedResponse } from './utils/rules'; + +export const RuleList: FC = () => { + const dispatch = useDispatch(); + const styles = useStyles(getStyles); + const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); + + // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS + useEffect(() => { + dispatch(fetchAllPromAndRulerRules()); + const interval = setInterval(() => dispatch(fetchAllPromAndRulerRules()), RULE_LIST_POLL_INTERVAL_MS); + return () => { + clearInterval(interval); + }; + }, [dispatch]); + + const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + + const dispatched = rulesDataSourceNames.some( + (name) => promRuleRequests[name]?.dispatched || rulerRuleRequests[name]?.dispatched + ); + const loading = rulesDataSourceNames.some( + (name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading + ); + const haveResults = rulesDataSourceNames.some( + (name) => + (promRuleRequests[name]?.result?.length && !promRuleRequests[name]?.error) || + (Object.keys(rulerRuleRequests[name]?.result || {}).length && !rulerRuleRequests[name]?.error) + ); + + const [promReqeustErrors, rulerRequestErrors] = useMemo( + () => + [promRuleRequests, rulerRuleRequests].map((requests) => + getRulesDataSources().reduce>( + (result, dataSource) => { + const error = requests[dataSource.name]?.error; + if (requests[dataSource.name] && error && !isRulerNotSupportedResponse(requests[dataSource.name])) { + return [...result, { dataSource, error }]; + } + return result; + }, + [] + ) + ), + [promRuleRequests, rulerRuleRequests] + ); + + const grafanaPromError = promRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; + const grafanaRulerError = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; + + const combinedNamespaces = useCombinedRuleNamespaces(); + const [thresholdNamespaces, systemNamespaces] = useMemo(() => { + const sorted = combinedNamespaces + .map((namespace) => ({ + ...namespace, + groups: namespace.groups.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + return [ + sorted.filter((ns) => ns.rulesSource === GRAFANA_RULES_SOURCE_NAME), + sorted.filter((ns) => isCloudRulesSource(ns.rulesSource)), + ]; + }, [combinedNamespaces]); + + return ( + + {(promReqeustErrors.length || rulerRequestErrors.length || grafanaPromError) && ( + + + Errors loading rules + + } + severity="error" + > + {grafanaPromError && ( +
Failed to load Grafana threshold rules state: {grafanaPromError.message || 'Unknown error.'}
+ )} + {grafanaRulerError && ( +
Failed to load Grafana threshold rules config: {grafanaRulerError.message || 'Unknown error.'}
+ )} + {promReqeustErrors.map(({ dataSource, error }) => ( +
+ Failed to load rules state from {dataSource.name}:{' '} + {error.message || 'Unknown error.'} +
+ ))} + {rulerRequestErrors.map(({ dataSource, error }) => ( +
+ Failed to load rules config from {dataSource.name}:{' '} + {error.message || 'Unknown error.'} +
+ ))} +
+ )} +
+ + {dispatched && !loading && !haveResults && } + {haveResults && } + {haveResults && } + + ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + iconError: css` + color: ${theme.palette.red}; + margin-right: ${theme.spacing.md}; + `, + buttonsContainer: css` + margin-bottom: ${theme.spacing.md}; + display: flex; + justify-content: space-between; + `, +}); diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts new file mode 100644 index 00000000000..3ddac795121 --- /dev/null +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -0,0 +1,39 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; + +// "grafana" for grafana-managed, otherwise a datasource name +export async function fetchAlertManagerConfig(alertmanagerSourceName: string): Promise { + try { + const result = await getBackendSrv() + .fetch({ + url: `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/config/api/v1/alerts`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + return result.data; + } catch (e) { + // if no config has been uploaded to grafana, it returns error instead of latest config + if ( + alertmanagerSourceName === GRAFANA_RULES_SOURCE_NAME && + e.data?.message?.includes('failed to get latest configuration') + ) { + return { + template_files: {}, + alertmanager_config: {}, + }; + } + throw e; + } +} + +export async function updateAlertmanagerConfig( + alertmanagerSourceName: string, + config: AlertManagerCortexConfig +): Promise { + await getBackendSrv().post( + `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/config/api/v1/alerts`, + config + ); +} diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts new file mode 100644 index 00000000000..99df8cf57ae --- /dev/null +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -0,0 +1,29 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { RuleNamespace } from 'app/types/unified-alerting'; +import { PromRulesResponse } from 'app/types/unified-alerting-dto'; +import { getDatasourceAPIId } from '../utils/datasource'; + +export async function fetchRules(dataSourceName: string): Promise { + const response = await getBackendSrv() + .fetch({ + url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + + const nsMap: { [key: string]: RuleNamespace } = {}; + response.data.data.groups.forEach((group) => { + if (!nsMap[group.file]) { + nsMap[group.file] = { + dataSourceName, + name: group.file, + groups: [group], + }; + } else { + nsMap[group.file].groups.push(group); + } + }); + + return Object.values(nsMap); +} diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts new file mode 100644 index 00000000000..656876f19d2 --- /dev/null +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -0,0 +1,81 @@ +import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { getDatasourceAPIId } from '../utils/datasource'; +import { getBackendSrv } from '@grafana/runtime'; +import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; + +// upsert a rule group. use this to update rules +export async function setRulerRuleGroup( + dataSourceName: string, + namespace: string, + group: RulerRuleGroupDTO +): Promise { + await getBackendSrv().post( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + group + ); +} + +// fetch all ruler rule namespaces and included groups +export async function fetchRulerRules(dataSourceName: string) { + return rulerGetRequest(`/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, {}); +} + +// fetch rule groups for a particular namespace +// will throw with { status: 404 } if namespace does not exist +export async function fetchRulerRulesNamespace(dataSourceName: string, namespace: string) { + const result = await rulerGetRequest>( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + {} + ); + return result[namespace] || []; +} + +// fetch a particular rule group +// will throw with { status: 404 } if rule group does not exist +export async function fetchRulerRulesGroup( + dataSourceName: string, + namespace: string, + group: string +): Promise { + return rulerGetRequest( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( + namespace + )}/${encodeURIComponent(group)}`, + null + ); +} + +export async function deleteRulerRulesGroup(dataSourceName: string, namespace: string, groupName: string) { + return getBackendSrv().delete( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( + namespace + )}/${encodeURIComponent(groupName)}` + ); +} + +// false in case ruler is not supported. this is weird, but we'll work on it +async function rulerGetRequest(url: string, empty: T): Promise { + try { + const response = await getBackendSrv() + .fetch({ + url, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + return response.data; + } catch (e) { + if (e?.status === 404) { + return empty; + } else if (e?.status === 500 && e?.data?.message?.includes('mapping values are not allowed in this context')) { + throw { + ...e, + data: { + ...e?.data, + message: RULER_NOT_SUPPORTED_MSG, + }, + }; + } + throw e; + } +} diff --git a/public/app/features/alerting/unified/components/AlertLabel.tsx b/public/app/features/alerting/unified/components/AlertLabel.tsx new file mode 100644 index 00000000000..3d846fbfa56 --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertLabel.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +interface Props { + labelKey: string; + value: string; +} + +export const AlertLabel: FC = ({ labelKey, value }) => ( +
+ {labelKey}={value} +
+); + +export const getStyles = (theme: GrafanaTheme) => css` + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + border-radius: ${theme.border.radius.sm}; + border: solid 1px ${theme.colors.border2}; + font-size: ${theme.typography.size.sm}; + background-color: ${theme.colors.bg2}; + font-weight: ${theme.typography.weight.bold}; + color: ${theme.colors.formLabel}; + display: inline-block; + line-height: 1.2; +`; diff --git a/public/app/features/alerting/unified/components/AlertLabels.tsx b/public/app/features/alerting/unified/components/AlertLabels.tsx new file mode 100644 index 00000000000..dce7f7cb489 --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertLabels.tsx @@ -0,0 +1,31 @@ +import { GrafanaTheme } from '@grafana/data'; +import { useStyles } from '@grafana/ui'; +import { css } from '@emotion/css'; +import React, { FC } from 'react'; +import { AlertLabel } from './AlertLabel'; + +interface Props { + labels: Record; +} + +export const AlertLabels: FC = ({ labels }) => { + const styles = useStyles(getStyles); + + return ( +
+ {Object.entries(labels).map(([k, v]) => ( + + ))} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + & > * { + margin-top: ${theme.spacing.xs}; + margin-right: ${theme.spacing.xs}; + } + padding-bottom: ${theme.spacing.xs}; + `, +}); diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx new file mode 100644 index 00000000000..cc594fcc7da --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -0,0 +1,46 @@ +import { SelectableValue } from '@grafana/data'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import React, { FC, useMemo } from 'react'; +import { Select } from '@grafana/ui'; +import { getAllDataSources } from '../utils/config'; + +interface Props { + onChange: (alertManagerSourceName?: string) => void; + current?: string; +} + +export const AlertManagerPicker: FC = ({ onChange, current }) => { + const options: Array> = useMemo(() => { + return [ + { + label: 'Grafana', + value: GRAFANA_RULES_SOURCE_NAME, + imgUrl: 'public/img/grafana_icon.svg', + meta: {}, + }, + ...getAllDataSources() + .filter((ds) => ds.type === DataSourceType.Alertmanager) + .map((ds) => ({ + label: ds.name.substr(0, 37), + value: ds.name, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + })), + ]; + }, []); + + return ( + + + + + +
+ + ); +}; + +export default AlertConditionsSection; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx new file mode 100644 index 00000000000..95978ea5ea5 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx @@ -0,0 +1,17 @@ +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) => { + return ( +
+ + +
+ ); +}; + +export default AlertDetails; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx new file mode 100644 index 00000000000..262e62c7402 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -0,0 +1,129 @@ +import React, { FC, useState } from 'react'; +import { GrafanaTheme, SelectableValue } from '@grafana/data'; +import { PageToolbar, ToolbarButton, stylesFactory, Form, FormAPI } 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 { fetchRulerRulesNamespace, setRulerRuleGroup } from '../../api/ruler'; +import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { locationService } from '@grafana/runtime'; + +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 }>; +} + +export type AlertRuleFormMethods = FormAPI; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + fullWidth: css` + width: 100%; + `, + formWrapper: css` + padding: 0 ${theme.spacing.md}; + `, + formInput: css` + width: 400px; + & + & { + margin-left: ${theme.spacing.sm}; + } + `, + flexRow: css` + display: flex; + flex-direction: row; + justify-content: flex-start; + `, + }; +}); + +const AlertRuleForm: FC = () => { + 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), + annotations: annotations.reduce((acc, { key, value }) => { + if (key && value) { + acc[key.value] = value; + } + return acc; + }, {} as Record), + }; + + 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 ( +
+ {(formApi) => ( + <> + + + Save + + Save and exit + + + Cancel + + + +
+ + + + +
+ + )} +
+ ); +}; + +export default AlertRuleForm; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx new file mode 100644 index 00000000000..4e804d1d1bd --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx @@ -0,0 +1,149 @@ +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 = ({ 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 ( +
+ + + +
+ + + + + + +
+ + { + const [namespace, group] = value.split(' > '); + setFolder({ namespace, group }); + }} + /> + +
+ ); +}; + +const useDatasourceSelectOptions = (alertType: SelectableValue) => { + const [datasourceOptions, setDataSourceOptions] = useState([]); + + useEffect(() => { + let options = [] as ReturnType; + 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([]); + + 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; diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx new file mode 100644 index 00000000000..00d5c34f9fd --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx @@ -0,0 +1,116 @@ +import React, { FC } from 'react'; +import { + Button, + Field, + FieldArray, + FormAPI, + IconButton, + InputControl, + Label, + Select, + TextArea, + stylesFactory, +} from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { config } from 'app/core/config'; +import { css, cx } from '@emotion/css'; + +interface Props extends FormAPI {} + +enum AnnotationOptions { + summary = 'Summary', + description = 'Description', + runbook = 'Runbook url', +} + +const AnnotationsField: FC = ({ control, register }) => { + const styles = getStyles(config.theme); + const annotationOptions = Object.entries(AnnotationOptions).map(([key, value]) => ({ value: key, label: value })); + + return ( + <> + + + {({ fields, append, remove }) => { + return ( +
+ {fields.map((field, index) => { + return ( +
+ + + + +