mirror of https://github.com/grafana/grafana.git
				
				
				
			Alerting: unified alerting frontend (#32708)
This commit is contained in:
		
							parent
							
								
									6082a9360e
								
							
						
					
					
						commit
						a56293142a
					
				|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										2
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										2
									
								
								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 | ||||
|  |  | |||
							
								
								
									
										2
									
								
								go.sum
								
								
								
								
							
							
						
						
									
										2
									
								
								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= | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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<Props> = ({ isOpen, onToggle, ...otherProps }) => { | ||||
|  | @ -120,7 +122,15 @@ export const ControlledCollapse: FunctionComponent<Props> = ({ isOpen, onToggle, | |||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const Collapse: FunctionComponent<Props> = ({ isOpen, label, loading, collapsible, onToggle, children }) => { | ||||
| export const Collapse: FunctionComponent<Props> = ({ | ||||
|   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<Props> = ({ 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]); | ||||
|  |  | |||
|  | @ -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{ | ||||
|  |  | |||
|  | @ -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{}), | ||||
| 	) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -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()) | ||||
|  |  | |||
|  | @ -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<HTMLDivElement> { | ||||
|   children: React.ReactNode; | ||||
|   navModel: NavModel; | ||||
|   contentWidth?: keyof GrafanaTheme['breakpoints']; | ||||
| } | ||||
| 
 | ||||
| export interface PageType extends FC<Props> { | ||||
|  | @ -21,7 +22,7 @@ export interface PageType extends FC<Props> { | |||
|   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 ( | ||||
|     <div {...otherProps} className={styles.wrapper}> | ||||
|     <div {...otherProps} className={cx(styles.wrapper, className)}> | ||||
|       <CustomScrollbar autoHeightMin={'100%'}> | ||||
|         <div className="page-scrollbar-content"> | ||||
|           <PageHeader model={navModel} /> | ||||
|           <PageHeader model={navModel} contentWidth={contentWidth} /> | ||||
|           {children} | ||||
|           <Footer /> | ||||
|         </div> | ||||
|  |  | |||
|  | @ -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<Props> = ({ isLoading, children }) => { | ||||
|   return <div className="page-container page-body">{isLoading ? <PageLoader /> : children}</div>; | ||||
| export const PageContents: FC<Props> = ({ isLoading, children, className }) => { | ||||
|   return <div className={cx('page-container', 'page-body', className)}>{isLoading ? <PageLoader /> : children}</div>; | ||||
| }; | ||||
|  |  | |||
|  | @ -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<Props> = ({ model }) => { | ||||
| export const PageHeader: FC<Props> = ({ model, contentWidth }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   if (!model) { | ||||
|  | @ -83,7 +84,7 @@ export const PageHeader: FC<Props> = ({ model }) => { | |||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.headerCanvas}> | ||||
|       <div className="page-container"> | ||||
|       <div className={cx('page-container', contentWidth ? styles.contentWidth(contentWidth) : undefined)}> | ||||
|         <div className="page-header"> | ||||
|           {renderHeaderTitle(main)} | ||||
|           {children && children.length && <Navigation>{children}</Navigation>} | ||||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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]; | ||||
| } | ||||
|  | @ -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; | ||||
|  | @ -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( | ||||
|  |  | |||
|  | @ -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 ( | ||||
|     <AlertingPageWrapper pageId="am-routes"> | ||||
|       <AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} /> | ||||
|       <br /> | ||||
|       <br /> | ||||
|       {error && !loading && ( | ||||
|         <InfoBox severity="error" title={<h4>Error loading alert manager config</h4>}> | ||||
|           {error.message || 'Unknown error.'} | ||||
|         </InfoBox> | ||||
|       )} | ||||
|       {loading && <LoadingPlaceholder text="loading alert manager config..." />} | ||||
|       {result && !loading && !error && <pre>{JSON.stringify(result, null, 2)}</pre>} | ||||
|     </AlertingPageWrapper> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default AmRoutes; | ||||
|  | @ -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( | ||||
|     <Provider store={store}> | ||||
|       <RuleList /> | ||||
|     </Provider> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 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<HTMLTableRowElement>(':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<HTMLTableRowElement>(':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<HTMLTableRowElement>(':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<HTMLTableRowElement>(':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<HTMLTableRowElement>(':scope > tbody > tr')).toHaveLength(2); | ||||
|     userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1])); | ||||
|     expect(table.querySelectorAll<HTMLTableRowElement>(':scope > tbody > tr')).toHaveLength(4); | ||||
|     userEvent.click(ui.groupCollapseToggle.get(groups[1])); | ||||
|     expect(ui.rulesTable.query()).not.toBeInTheDocument(); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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<Array<{ error: SerializedError; dataSource: DataSourceInstanceSettings }>>( | ||||
|           (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 ( | ||||
|     <AlertingPageWrapper pageId="alert-list" isLoading={loading && !haveResults}> | ||||
|       {(promReqeustErrors.length || rulerRequestErrors.length || grafanaPromError) && ( | ||||
|         <InfoBox | ||||
|           data-testid="cloud-rulessource-errors" | ||||
|           title={ | ||||
|             <h4> | ||||
|               <Icon className={styles.iconError} name="exclamation-triangle" size="xl" /> | ||||
|               Errors loading rules | ||||
|             </h4> | ||||
|           } | ||||
|           severity="error" | ||||
|         > | ||||
|           {grafanaPromError && ( | ||||
|             <div>Failed to load Grafana threshold rules state: {grafanaPromError.message || 'Unknown error.'}</div> | ||||
|           )} | ||||
|           {grafanaRulerError && ( | ||||
|             <div>Failed to load Grafana threshold rules config: {grafanaRulerError.message || 'Unknown error.'}</div> | ||||
|           )} | ||||
|           {promReqeustErrors.map(({ dataSource, error }) => ( | ||||
|             <div key={dataSource.name}> | ||||
|               Failed to load rules state from <a href={`datasources/edit/${dataSource.id}`}>{dataSource.name}</a>:{' '} | ||||
|               {error.message || 'Unknown error.'} | ||||
|             </div> | ||||
|           ))} | ||||
|           {rulerRequestErrors.map(({ dataSource, error }) => ( | ||||
|             <div key={dataSource.name}> | ||||
|               Failed to load rules config from <a href={`datasources/edit/${dataSource.id}`}>{dataSource.name}</a>:{' '} | ||||
|               {error.message || 'Unknown error.'} | ||||
|             </div> | ||||
|           ))} | ||||
|         </InfoBox> | ||||
|       )} | ||||
|       <div className={styles.buttonsContainer}> | ||||
|         <div /> | ||||
|         <a href="/alerting/new"> | ||||
|           <Button icon="plus">New alert rule</Button> | ||||
|         </a> | ||||
|       </div> | ||||
|       {dispatched && !loading && !haveResults && <NoRulesSplash />} | ||||
|       {haveResults && <ThresholdRules namespaces={thresholdNamespaces} />} | ||||
|       {haveResults && <SystemOrApplicationRules namespaces={systemNamespaces} />} | ||||
|     </AlertingPageWrapper> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 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; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -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<AlertManagerCortexConfig> { | ||||
|   try { | ||||
|     const result = await getBackendSrv() | ||||
|       .fetch<AlertManagerCortexConfig>({ | ||||
|         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<void> { | ||||
|   await getBackendSrv().post( | ||||
|     `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/config/api/v1/alerts`, | ||||
|     config | ||||
|   ); | ||||
| } | ||||
|  | @ -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<RuleNamespace[]> { | ||||
|   const response = await getBackendSrv() | ||||
|     .fetch<PromRulesResponse>({ | ||||
|       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); | ||||
| } | ||||
|  | @ -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<void> { | ||||
|   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<RulerRulesConfigDTO>(`/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<Record<string, RulerRuleGroupDTO[]>>( | ||||
|     `/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<RulerRuleGroupDTO | null> { | ||||
|   return rulerGetRequest<RulerRuleGroupDTO | null>( | ||||
|     `/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<T>(url: string, empty: T): Promise<T> { | ||||
|   try { | ||||
|     const response = await getBackendSrv() | ||||
|       .fetch<T>({ | ||||
|         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; | ||||
|   } | ||||
| } | ||||
|  | @ -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<Props> = ({ labelKey, value }) => ( | ||||
|   <div className={useStyles(getStyles)}> | ||||
|     {labelKey}={value} | ||||
|   </div> | ||||
| ); | ||||
| 
 | ||||
| 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; | ||||
| `;
 | ||||
|  | @ -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<string, string>; | ||||
| } | ||||
| 
 | ||||
| export const AlertLabels: FC<Props> = ({ labels }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.wrapper}> | ||||
|       {Object.entries(labels).map(([k, v]) => ( | ||||
|         <AlertLabel key={`${k}-${v}`} labelKey={k} value={v} /> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   wrapper: css` | ||||
|     & > * { | ||||
|       margin-top: ${theme.spacing.xs}; | ||||
|       margin-right: ${theme.spacing.xs}; | ||||
|     } | ||||
|     padding-bottom: ${theme.spacing.xs}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -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<Props> = ({ onChange, current }) => { | ||||
|   const options: Array<SelectableValue<string>> = 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 ( | ||||
|     <Select | ||||
|       className="ds-picker select-container" | ||||
|       isMulti={false} | ||||
|       isClearable={false} | ||||
|       backspaceRemovesValue={false} | ||||
|       onChange={(value) => onChange(value.value)} | ||||
|       options={options} | ||||
|       maxMenuHeight={500} | ||||
|       noOptionsMessage="No datasources found" | ||||
|       value={current} | ||||
|       getOptionLabel={(o) => o.label} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1,23 @@ | |||
| import React, { FC } from 'react'; | ||||
| import Page from 'app/core/components/Page/Page'; | ||||
| import { getNavModel } from 'app/core/selectors/navModel'; | ||||
| import { useSelector } from 'react-redux'; | ||||
| import { StoreState } from 'app/types/store'; | ||||
| 
 | ||||
| interface Props { | ||||
|   pageId: string; | ||||
|   isLoading?: boolean; | ||||
| } | ||||
| 
 | ||||
| export const AlertingPageWrapper: FC<Props> = ({ children, pageId, isLoading }) => { | ||||
|   const navModel = getNavModel( | ||||
|     useSelector((state: StoreState) => state.navIndex), | ||||
|     pageId | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <Page navModel={navModel} contentWidth="xxl"> | ||||
|       <Page.Contents isLoading={isLoading}>{children}</Page.Contents> | ||||
|     </Page> | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1,36 @@ | |||
| import React, { FC } from 'react'; | ||||
| import { Well } from './Well'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { css } from '@emotion/css'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| 
 | ||||
| const wellableAnnotationKeys = ['message', 'description']; | ||||
| 
 | ||||
| interface Props { | ||||
|   annotationKey: string; | ||||
|   value: string; | ||||
| } | ||||
| 
 | ||||
| export const Annotation: FC<Props> = ({ annotationKey, value }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
|   if (wellableAnnotationKeys.includes(annotationKey)) { | ||||
|     return <Well>{value}</Well>; | ||||
|   } else if (value && value.startsWith('http')) { | ||||
|     return ( | ||||
|       <a href={value} target="__blank" className={styles.link}> | ||||
|         {value} | ||||
|       </a> | ||||
|     ); | ||||
|   } | ||||
|   return <>{value}</>; | ||||
| }; | ||||
| 
 | ||||
| export const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   well: css` | ||||
|     word-break: break-all; | ||||
|   `,
 | ||||
|   link: css` | ||||
|     word-break: break-all; | ||||
|     color: ${theme.colors.textBlue}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,33 @@ | |||
| import React, { FC, HTMLAttributes } from 'react'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import { IconSize, useStyles, Icon } from '@grafana/ui'; | ||||
| 
 | ||||
| interface Props extends HTMLAttributes<HTMLButtonElement> { | ||||
|   isCollapsed: boolean; | ||||
|   onToggle: (isCollapsed: boolean) => void; | ||||
|   size?: IconSize; | ||||
|   className?: string; | ||||
| } | ||||
| 
 | ||||
| export const CollapseToggle: FC<Props> = ({ isCollapsed, onToggle, className, size = 'xl', ...restOfProps }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   return ( | ||||
|     <button className={cx(styles.expandButton, className)} onClick={() => onToggle(!isCollapsed)} {...restOfProps}> | ||||
|       <Icon size={size} name={isCollapsed ? 'angle-right' : 'angle-down'} /> | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const getStyles = () => ({ | ||||
|   expandButton: css` | ||||
|     background: none; | ||||
|     border: none; | ||||
| 
 | ||||
|     outline: none !important; | ||||
| 
 | ||||
|     svg { | ||||
|       margin-bottom: 0; | ||||
|     } | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,55 @@ | |||
| import { Editor } from '@grafana/slate-react'; | ||||
| import React, { FC, useMemo } from 'react'; | ||||
| import PromqlSyntax from 'app/plugins/datasource/prometheus/promql'; | ||||
| import LogqlSyntax from 'app/plugins/datasource/loki/syntax'; | ||||
| import { LanguageMap, languages as prismLanguages } from 'prismjs'; | ||||
| import { makeValue, SlatePrism, useStyles } from '@grafana/ui'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { RulesSource } from 'app/types/unified-alerting'; | ||||
| import { DataSourceType, isCloudRulesSource } from '../utils/datasource'; | ||||
| import { Well } from './Well'; | ||||
| 
 | ||||
| interface Props { | ||||
|   query: string; | ||||
|   rulesSource: RulesSource; | ||||
| } | ||||
| 
 | ||||
| export const HighlightedQuery: FC<{ language: 'promql' | 'logql'; expr: string }> = ({ language, expr }) => { | ||||
|   const plugins = useMemo( | ||||
|     () => [ | ||||
|       SlatePrism( | ||||
|         { | ||||
|           onlyIn: (node: any) => node.type === 'code_block', | ||||
|           getSyntax: () => language, | ||||
|         }, | ||||
|         { ...(prismLanguages as LanguageMap), [language]: language === 'logql' ? LogqlSyntax : PromqlSyntax } | ||||
|       ), | ||||
|     ], | ||||
|     [language] | ||||
|   ); | ||||
| 
 | ||||
|   const slateValue = useMemo(() => makeValue(expr), [expr]); | ||||
| 
 | ||||
|   return <Editor plugins={plugins} value={slateValue} readOnly={true} />; | ||||
| }; | ||||
| 
 | ||||
| export const RuleQuery: FC<Props> = ({ query, rulesSource }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   return ( | ||||
|     <Well className={cx(styles.well, 'slate-query-field')}> | ||||
|       {isCloudRulesSource(rulesSource) ? ( | ||||
|         <HighlightedQuery expr={query} language={rulesSource.type === DataSourceType.Loki ? 'logql' : 'promql'} /> | ||||
|       ) : ( | ||||
|         query | ||||
|       )} | ||||
|     </Well> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   well: css` | ||||
|     font-family: ${theme.typography.fontFamily.monospace}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,27 @@ | |||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; | ||||
| import { css } from '@emotion/css'; | ||||
| import React, { FC } from 'react'; | ||||
| 
 | ||||
| type Props = { | ||||
|   status: PromAlertingRuleState; | ||||
| }; | ||||
| 
 | ||||
| export const StateColoredText: FC<Props> = ({ children, status }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   return <span className={styles[status]}>{children || status}</span>; | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   [PromAlertingRuleState.Inactive]: css` | ||||
|     color: ${theme.palette.brandSuccess}; | ||||
|   `,
 | ||||
|   [PromAlertingRuleState.Pending]: css` | ||||
|     color: ${theme.palette.brandWarning}; | ||||
|   `,
 | ||||
|   [PromAlertingRuleState.Firing]: css` | ||||
|     color: ${theme.palette.brandDanger}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,39 @@ | |||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import React, { FC } from 'react'; | ||||
| 
 | ||||
| type Props = { | ||||
|   status: PromAlertingRuleState; | ||||
| }; | ||||
| 
 | ||||
| export const StateTag: FC<Props> = ({ children, status }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   return <span className={cx(styles.common, styles[status])}>{children || status}</span>; | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   common: css` | ||||
|     display: inline-block; | ||||
|     color: white; | ||||
|     border-radius: ${theme.border.radius.sm}; | ||||
|     font-size: ${theme.typography.size.sm}; | ||||
|     padding: ${theme.spacing.xs} ${theme.spacing.sm}; | ||||
|     text-transform: capitalize; | ||||
|     line-height: 1.2; | ||||
|   `,
 | ||||
|   [PromAlertingRuleState.Inactive]: css` | ||||
|     background-color: ${theme.palette.brandSuccess}; | ||||
|     border: solid 1px ${theme.palette.brandSuccess}; | ||||
|   `,
 | ||||
|   [PromAlertingRuleState.Pending]: css` | ||||
|     background-color: ${theme.palette.brandWarning}; | ||||
|     border: solid 1px ${theme.palette.brandWarning}; | ||||
|   `,
 | ||||
|   [PromAlertingRuleState.Firing]: css` | ||||
|     background-color: ${theme.palette.brandDanger}; | ||||
|     border: solid 1px ${theme.palette.brandDanger}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,15 @@ | |||
| import { dateTimeFormatTimeAgo, DateTimeInput } from '@grafana/data'; | ||||
| import React, { FC, useEffect, useState } from 'react'; | ||||
| 
 | ||||
| export interface Props { | ||||
|   date: DateTimeInput; | ||||
| } | ||||
| 
 | ||||
| export const TimeToNow: FC<Props> = ({ date }) => { | ||||
|   const setRandom = useState(0)[1]; | ||||
|   useEffect(() => { | ||||
|     const interval = setInterval(() => setRandom(Math.random()), 1000); | ||||
|     return () => clearInterval(interval); | ||||
|   }); | ||||
|   return <span title={String(date)}>{dateTimeFormatTimeAgo(date)}</span>; | ||||
| }; | ||||
|  | @ -0,0 +1,20 @@ | |||
| import React, { FC } from 'react'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| import { cx, css } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| 
 | ||||
| type Props = React.HTMLAttributes<HTMLDivElement>; | ||||
| 
 | ||||
| export const Well: FC<Props> = ({ children, className }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
|   return <div className={cx(styles.wrapper, className)}>{children}</div>; | ||||
| }; | ||||
| export const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   wrapper: css` | ||||
|     background-color: ${theme.colors.panelBg}; | ||||
|     border: solid 1px ${theme.colors.formInputBorder}; | ||||
|     border-radius: ${theme.border.radius.sm}; | ||||
|     padding: ${theme.spacing.xs} ${theme.spacing.sm}; | ||||
|     font-family: ${theme.typography.fontFamily.monospace}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,53 @@ | |||
| import React, { FC } from 'react'; | ||||
| import { Field, FieldSet, Input, Select, useStyles, Label, InputControl } from '@grafana/ui'; | ||||
| import { css } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { AlertRuleFormMethods } from './AlertRuleForm'; | ||||
| 
 | ||||
| type Props = AlertRuleFormMethods; | ||||
| 
 | ||||
| enum TIME_OPTIONS { | ||||
|   seconds = 's', | ||||
|   minutes = 'm', | ||||
|   hours = 'h', | ||||
|   days = 'd', | ||||
| } | ||||
| 
 | ||||
| const timeOptions = Object.entries(TIME_OPTIONS).map(([key, value]) => ({ | ||||
|   label: key, | ||||
|   value: value, | ||||
| })); | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   flexRow: css` | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: flex-end; | ||||
|     justify-content: flex-start; | ||||
|   `,
 | ||||
|   numberInput: css` | ||||
|     width: 200px; | ||||
|     & + & { | ||||
|       margin-left: ${theme.spacing.sm}; | ||||
|     } | ||||
|   `,
 | ||||
| }); | ||||
| 
 | ||||
| const AlertConditionsSection: FC<Props> = ({ register, control }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
|   return ( | ||||
|     <FieldSet label="Define alert conditions"> | ||||
|       <Label description="Required time for which the expression has to happen">For</Label> | ||||
|       <div className={styles.flexRow}> | ||||
|         <Field className={styles.numberInput}> | ||||
|           <Input ref={register()} name="forTime" /> | ||||
|         </Field> | ||||
|         <Field className={styles.numberInput}> | ||||
|           <InputControl name="timeUnit" as={Select} options={timeOptions} control={control} /> | ||||
|         </Field> | ||||
|       </div> | ||||
|     </FieldSet> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default AlertConditionsSection; | ||||
|  | @ -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> = (props) => { | ||||
|   return ( | ||||
|     <FieldSet label="Add details for your alert"> | ||||
|       <AnnotationsField {...props} /> | ||||
|       <LabelsField {...props} /> | ||||
|     </FieldSet> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default AlertDetails; | ||||
|  | @ -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<AlertRuleFormFields>; | ||||
| 
 | ||||
| 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<Props> = () => { | ||||
|   const styles = getStyles(config.theme); | ||||
| 
 | ||||
|   const [folder, setFolder] = useState<{ namespace: string; group: string }>(); | ||||
| 
 | ||||
|   const handleSubmit = (alertRule: AlertRuleFormFields) => { | ||||
|     const { name, expression, forTime, dataSource, timeUnit, labels, annotations } = alertRule; | ||||
|     console.log('saving', alertRule); | ||||
|     const { namespace, group: groupName } = folder || {}; | ||||
|     if (namespace && groupName) { | ||||
|       fetchRulerRulesNamespace(dataSource?.value, namespace) | ||||
|         .then((ruleGroup) => { | ||||
|           const group: RulerRuleGroupDTO = ruleGroup.find(({ name }) => name === groupName) || { | ||||
|             name: groupName, | ||||
|             rules: [] as RulerRuleDTO[], | ||||
|           }; | ||||
|           const alertRule: RulerRuleDTO = { | ||||
|             alert: name, | ||||
|             expr: expression, | ||||
|             for: `${forTime}${timeUnit.value}`, | ||||
|             labels: labels.reduce((acc, { key, value }) => { | ||||
|               if (key && value) { | ||||
|                 acc[key] = value; | ||||
|               } | ||||
|               return acc; | ||||
|             }, {} as Record<string, string>), | ||||
|             annotations: annotations.reduce((acc, { key, value }) => { | ||||
|               if (key && value) { | ||||
|                 acc[key.value] = value; | ||||
|               } | ||||
|               return acc; | ||||
|             }, {} as Record<string, string>), | ||||
|           }; | ||||
| 
 | ||||
|           group.rules = group?.rules.concat(alertRule); | ||||
|           return setRulerRuleGroup(dataSource?.value, namespace, group); | ||||
|         }) | ||||
|         .then(() => { | ||||
|           console.log('Alert rule saved successfully'); | ||||
|           locationService.push('/alerting/list'); | ||||
|         }) | ||||
|         .catch((error) => console.error(error)); | ||||
|     } | ||||
|   }; | ||||
|   return ( | ||||
|     <Form | ||||
|       onSubmit={handleSubmit} | ||||
|       className={styles.fullWidth} | ||||
|       defaultValues={{ labels: [{ key: '', value: '' }], annotations: [{ key: {}, value: '' }] }} | ||||
|     > | ||||
|       {(formApi) => ( | ||||
|         <> | ||||
|           <PageToolbar title="Create alert rule" pageIcon="bell"> | ||||
|             <ToolbarButton variant="primary" type="submit"> | ||||
|               Save | ||||
|             </ToolbarButton> | ||||
|             <ToolbarButton variant="primary">Save and exit</ToolbarButton> | ||||
|             <a href="/alerting/list"> | ||||
|               <ToolbarButton variant="destructive" type="button"> | ||||
|                 Cancel | ||||
|               </ToolbarButton> | ||||
|             </a> | ||||
|           </PageToolbar> | ||||
|           <div className={styles.formWrapper}> | ||||
|             <AlertTypeSection {...formApi} setFolder={setFolder} /> | ||||
|             <Expression {...formApi} /> | ||||
|             <AlertConditionsSection {...formApi} /> | ||||
|             <AlertDetails {...formApi} /> | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
|     </Form> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default AlertRuleForm; | ||||
|  | @ -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<Props> = ({ register, control, watch, setFolder, errors }) => { | ||||
|   const styles = getStyles(config.theme); | ||||
| 
 | ||||
|   const alertType = watch('type') as SelectableValue; | ||||
|   const datasource = watch('dataSource') as SelectableValue; | ||||
|   const dataSourceOptions = useDatasourceSelectOptions(alertType); | ||||
|   const folderOptions = useFolderSelectOptions(datasource); | ||||
| 
 | ||||
|   return ( | ||||
|     <FieldSet label="Alert type"> | ||||
|       <Field | ||||
|         className={styles.formInput} | ||||
|         label="Alert name" | ||||
|         error={errors?.name?.message} | ||||
|         invalid={!!errors.name?.message} | ||||
|       > | ||||
|         <Input ref={register({ required: { value: true, message: 'Must enter an alert name' } })} name="name" /> | ||||
|       </Field> | ||||
|       <div className={styles.flexRow}> | ||||
|         <Field label="Alert type" className={styles.formInput} error={errors.type?.message}> | ||||
|           <InputControl as={Select} name="type" options={alertTypeOptions} control={control} /> | ||||
|         </Field> | ||||
|         <Field className={styles.formInput} label="Select data source"> | ||||
|           <InputControl as={Select} name="dataSource" options={dataSourceOptions} control={control} /> | ||||
|         </Field> | ||||
|       </div> | ||||
|       <Field className={styles.formInput}> | ||||
|         <InputControl | ||||
|           as={Cascader} | ||||
|           displayAllSelectedLevels={true} | ||||
|           separator=" > " | ||||
|           name="folder" | ||||
|           options={folderOptions} | ||||
|           control={control} | ||||
|           changeOnSelect={false} | ||||
|           onSelect={(value: string) => { | ||||
|             const [namespace, group] = value.split(' > '); | ||||
|             setFolder({ namespace, group }); | ||||
|           }} | ||||
|         /> | ||||
|       </Field> | ||||
|     </FieldSet> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const useDatasourceSelectOptions = (alertType: SelectableValue) => { | ||||
|   const [datasourceOptions, setDataSourceOptions] = useState<SelectableValue[]>([]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     let options = [] as ReturnType<typeof getAllDataSources>; | ||||
|     if (alertType?.value === ALERT_TYPE.THRESHOLD) { | ||||
|       options = getAllDataSources().filter(({ type }) => type !== 'datasource'); | ||||
|     } else if (alertType?.value === ALERT_TYPE.SYSTEM) { | ||||
|       options = getRulesDataSources(); | ||||
|     } | ||||
|     setDataSourceOptions( | ||||
|       options.map(({ name, type }) => { | ||||
|         return { | ||||
|           label: name, | ||||
|           value: name, | ||||
|           description: type, | ||||
|         }; | ||||
|       }) | ||||
|     ); | ||||
|   }, [alertType?.value]); | ||||
| 
 | ||||
|   return datasourceOptions; | ||||
| }; | ||||
| 
 | ||||
| const useFolderSelectOptions = (datasource: SelectableValue) => { | ||||
|   const [folderOptions, setFolderOptions] = useState<CascaderOption[]>([]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (datasource?.value) { | ||||
|       fetchRulerRules(datasource?.value) | ||||
|         .then((namespaces) => { | ||||
|           const options: CascaderOption[] = Object.entries(namespaces).map(([namespace, group]) => { | ||||
|             return { | ||||
|               label: namespace, | ||||
|               value: namespace, | ||||
|               items: group.map(({ name }) => { | ||||
|                 return { label: name, value: `${namespace} > ${name}` }; | ||||
|               }), | ||||
|             }; | ||||
|           }); | ||||
|           setFolderOptions(options); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           if (error.status === 404) { | ||||
|             setFolderOptions([{ label: 'No folders found', value: '' }]); | ||||
|           } | ||||
|         }); | ||||
|     } | ||||
|   }, [datasource?.value]); | ||||
| 
 | ||||
|   return folderOptions; | ||||
| }; | ||||
| 
 | ||||
| const getStyles = stylesFactory((theme: GrafanaTheme) => { | ||||
|   return { | ||||
|     formInput: css` | ||||
|       width: 400px; | ||||
|       & + & { | ||||
|         margin-left: ${theme.spacing.sm}; | ||||
|       } | ||||
|     `,
 | ||||
|     flexRow: css` | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       justify-content: flex-start; | ||||
|     `,
 | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| export default AlertTypeSection; | ||||
|  | @ -0,0 +1,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<any> {} | ||||
| 
 | ||||
| enum AnnotationOptions { | ||||
|   summary = 'Summary', | ||||
|   description = 'Description', | ||||
|   runbook = 'Runbook url', | ||||
| } | ||||
| 
 | ||||
| const AnnotationsField: FC<Props> = ({ control, register }) => { | ||||
|   const styles = getStyles(config.theme); | ||||
|   const annotationOptions = Object.entries(AnnotationOptions).map(([key, value]) => ({ value: key, label: value })); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Label>Summary and annotations</Label> | ||||
|       <FieldArray name={'annotations'} control={control}> | ||||
|         {({ fields, append, remove }) => { | ||||
|           return ( | ||||
|             <div className={styles.flexColumn}> | ||||
|               {fields.map((field, index) => { | ||||
|                 return ( | ||||
|                   <div key={`${field.annotationKey}-${index}`} className={styles.flexRow}> | ||||
|                     <Field className={styles.annotationSelect}> | ||||
|                       <InputControl | ||||
|                         as={Select} | ||||
|                         name={`annotations[${index}].key`} | ||||
|                         options={annotationOptions} | ||||
|                         control={control} | ||||
|                         defaultValue={field.key} | ||||
|                       /> | ||||
|                     </Field> | ||||
|                     <Field className={cx(styles.annotationTextArea, styles.flexRowItemMargin)}> | ||||
|                       <TextArea | ||||
|                         name={`annotations[${index}].value`} | ||||
|                         ref={register()} | ||||
|                         placeholder={`Text`} | ||||
|                         defaultValue={field.value} | ||||
|                       /> | ||||
|                     </Field> | ||||
|                     <IconButton | ||||
|                       className={styles.flexRowItemMargin} | ||||
|                       aria-label="delete annotation" | ||||
|                       name="trash-alt" | ||||
|                       onClick={() => { | ||||
|                         remove(index); | ||||
|                       }} | ||||
|                     /> | ||||
|                   </div> | ||||
|                 ); | ||||
|               })} | ||||
|               <Button | ||||
|                 className={styles.addAnnotationsButton} | ||||
|                 icon="plus-circle" | ||||
|                 type="button" | ||||
|                 variant="secondary" | ||||
|                 size="sm" | ||||
|                 onClick={() => { | ||||
|                   append({}); | ||||
|                 }} | ||||
|               > | ||||
|                 Add info | ||||
|               </Button> | ||||
|             </div> | ||||
|           ); | ||||
|         }} | ||||
|       </FieldArray> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = stylesFactory((theme: GrafanaTheme) => { | ||||
|   return { | ||||
|     annotationSelect: css` | ||||
|       width: 120px; | ||||
|     `,
 | ||||
|     annotationTextArea: css` | ||||
|       width: 450px; | ||||
|       height: 76px; | ||||
|     `,
 | ||||
|     addAnnotationsButton: css` | ||||
|       flex-grow: 0; | ||||
|       align-self: flex-start; | ||||
|     `,
 | ||||
|     flexColumn: css` | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     `,
 | ||||
|     flexRow: css` | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       justify-content: flex-start; | ||||
|     `,
 | ||||
|     flexRowItemMargin: css` | ||||
|       margin-left: ${theme.spacing.sm}; | ||||
|     `,
 | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| export default AnnotationsField; | ||||
|  | @ -0,0 +1,17 @@ | |||
| import React, { FC } from 'react'; | ||||
| import { Field, FieldSet, Input } from '@grafana/ui'; | ||||
| import { AlertRuleFormMethods } from './AlertRuleForm'; | ||||
| 
 | ||||
| type Props = AlertRuleFormMethods; | ||||
| 
 | ||||
| const Expression: FC<Props> = ({ register }) => { | ||||
|   return ( | ||||
|     <FieldSet label="Create a query (expression) to be alerted on"> | ||||
|       <Field> | ||||
|         <Input ref={register()} name="expression" placeholder="Enter a PromQL query here" /> | ||||
|       </Field> | ||||
|     </FieldSet> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Expression; | ||||
|  | @ -0,0 +1,118 @@ | |||
| import React from 'react'; | ||||
| import { Button, Field, FieldArray, FormAPI, Input, InlineLabel, IconButton, Label, stylesFactory } from '@grafana/ui'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { config } from 'app/core/config'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| 
 | ||||
| interface Props extends Pick<FormAPI<{}>, 'register' | 'control'> { | ||||
|   className?: string; | ||||
| } | ||||
| 
 | ||||
| const LabelsField = (props: Props) => { | ||||
|   const styles = getStyles(config.theme); | ||||
|   const { register, control } = props; | ||||
|   return ( | ||||
|     <div className={props.className}> | ||||
|       <Label>Custom Labels</Label> | ||||
|       <FieldArray control={control} name="labels"> | ||||
|         {({ fields, append, remove }) => { | ||||
|           return ( | ||||
|             <> | ||||
|               <div className={styles.flexRow}> | ||||
|                 <InlineLabel width={12}>Labels</InlineLabel> | ||||
|                 <div className={styles.flexColumn}> | ||||
|                   {fields.map((field, index) => { | ||||
|                     return ( | ||||
|                       <div key={field.id}> | ||||
|                         <div className={cx(styles.flexRow, styles.centerAlignRow)}> | ||||
|                           <Field className={styles.labelInput}> | ||||
|                             <Input | ||||
|                               ref={register()} | ||||
|                               name={`labels[${index}].key`} | ||||
|                               placeholder="key" | ||||
|                               defaultValue={field.key} | ||||
|                             /> | ||||
|                           </Field> | ||||
|                           <div className={styles.equalSign}>=</div> | ||||
|                           <Field className={styles.labelInput}> | ||||
|                             <Input | ||||
|                               ref={register()} | ||||
|                               name={`labels[${index}].value`} | ||||
|                               placeholder="value" | ||||
|                               defaultValue={field.value} | ||||
|                             /> | ||||
|                           </Field> | ||||
|                           <IconButton | ||||
|                             aria-label="delete label" | ||||
|                             name="trash-alt" | ||||
|                             onClick={() => { | ||||
|                               remove(index); | ||||
|                             }} | ||||
|                           /> | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     ); | ||||
|                   })} | ||||
|                   <Button | ||||
|                     className={styles.addLabelButton} | ||||
|                     icon="plus-circle" | ||||
|                     type="button" | ||||
|                     variant="secondary" | ||||
|                     size="sm" | ||||
|                     onClick={() => { | ||||
|                       append({}); | ||||
|                     }} | ||||
|                   > | ||||
|                     Add label | ||||
|                   </Button> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </> | ||||
|           ); | ||||
|         }} | ||||
|       </FieldArray> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = stylesFactory((theme: GrafanaTheme) => { | ||||
|   return { | ||||
|     flexColumn: css` | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     `,
 | ||||
|     flexRow: css` | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       justify-content: flex-start; | ||||
| 
 | ||||
|       & + button { | ||||
|         margin-left: ${theme.spacing.xs}; | ||||
|       } | ||||
|     `,
 | ||||
|     addLabelButton: css` | ||||
|       flex-grow: 0; | ||||
|       align-self: flex-start; | ||||
|     `,
 | ||||
|     centerAlignRow: css` | ||||
|       align-items: baseline; | ||||
|     `,
 | ||||
|     equalSign: css` | ||||
|       width: ${theme.spacing.lg}; | ||||
|       height: ${theme.spacing.lg}; | ||||
|       padding: ${theme.spacing.sm}; | ||||
|       line-height: ${theme.spacing.sm}; | ||||
|       background-color: ${theme.colors.bg2}; | ||||
|       margin: 0 ${theme.spacing.xs}; | ||||
|     `,
 | ||||
|     labelInput: css` | ||||
|       width: 200px; | ||||
|       margin-bottom: ${theme.spacing.sm}; | ||||
|       & + & { | ||||
|         margin-left: ${theme.spacing.sm}; | ||||
|       } | ||||
|     `,
 | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| export default LabelsField; | ||||
|  | @ -0,0 +1,16 @@ | |||
| import { Button, ButtonProps } from '@grafana/ui/src/components/Button'; | ||||
| import React, { FC } from 'react'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| 
 | ||||
| type Props = Omit<ButtonProps, 'variant' | 'size'>; | ||||
| 
 | ||||
| export const ActionButton: FC<Props> = ({ className, ...restProps }) => ( | ||||
|   <Button variant="secondary" size="xs" className={cx(useStyles(getStyle), className)} {...restProps} /> | ||||
| ); | ||||
| 
 | ||||
| export const getStyle = (theme: GrafanaTheme) => css` | ||||
|   height: 24px; | ||||
|   font-size: ${theme.typography.size.sm}; | ||||
| `;
 | ||||
|  | @ -0,0 +1,38 @@ | |||
| import { Icon, IconName, useStyles, Tooltip } from '@grafana/ui'; | ||||
| import { PopoverContent } from '@grafana/ui/src/components/Tooltip/Tooltip'; | ||||
| import { TooltipPlacement } from '@grafana/ui/src/components/Tooltip/PopoverController'; | ||||
| import React, { FC } from 'react'; | ||||
| import { css } from '@emotion/css'; | ||||
| 
 | ||||
| interface Props { | ||||
|   tooltip: PopoverContent; | ||||
|   icon: IconName; | ||||
| 
 | ||||
|   tooltipPlacement?: TooltipPlacement; | ||||
|   href?: string; | ||||
|   target?: string; | ||||
|   onClick?: (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void; | ||||
| } | ||||
| 
 | ||||
| export const ActionIcon: FC<Props> = ({ tooltip, icon, href, target, onClick, tooltipPlacement = 'top' }) => { | ||||
|   const iconEl = <Icon className={useStyles(getStyle)} name={icon} />; | ||||
| 
 | ||||
|   return ( | ||||
|     <Tooltip content={tooltip} placement={tooltipPlacement}> | ||||
|       {(() => { | ||||
|         if (href || onClick) { | ||||
|           return ( | ||||
|             <a href={href} onClick={onClick} target={target}> | ||||
|               {iconEl} | ||||
|             </a> | ||||
|           ); | ||||
|         } | ||||
|         return iconEl; | ||||
|       })()} | ||||
|     </Tooltip> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const getStyle = () => css` | ||||
|   cursor: pointer; | ||||
| `;
 | ||||
|  | @ -0,0 +1,25 @@ | |||
| import { Alert } from 'app/types/unified-alerting'; | ||||
| import React, { FC } from 'react'; | ||||
| import { Annotation } from '../Annotation'; | ||||
| import { DetailsField } from './DetailsField'; | ||||
| 
 | ||||
| interface Props { | ||||
|   instance: Alert; | ||||
| } | ||||
| 
 | ||||
| export const AlertInstanceDetails: FC<Props> = ({ instance }) => { | ||||
|   const annotations = Object.entries(instance.annotations || {}) || []; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <DetailsField label="Value" horizontal={true}> | ||||
|         {instance.value} | ||||
|       </DetailsField> | ||||
|       {annotations.map(([key, value]) => ( | ||||
|         <DetailsField key={key} label={key} horizontal={true}> | ||||
|           <Annotation annotationKey={key} value={value} /> | ||||
|         </DetailsField> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1,103 @@ | |||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| import { AlertingRule } from 'app/types/unified-alerting'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import React, { FC, Fragment, useState } from 'react'; | ||||
| import { getAlertTableStyles } from '../../styles/table'; | ||||
| import { alertInstanceKey } from '../../utils/rules'; | ||||
| import { AlertLabels } from '../AlertLabels'; | ||||
| import { CollapseToggle } from '../CollapseToggle'; | ||||
| import { StateTag } from '../StateTag'; | ||||
| import { AlertInstanceDetails } from './AlertInstanceDetails'; | ||||
| 
 | ||||
| interface Props { | ||||
|   instances: AlertingRule['alerts']; | ||||
| } | ||||
| 
 | ||||
| export const AlertInstancesTable: FC<Props> = ({ instances }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
|   const tableStyles = useStyles(getAlertTableStyles); | ||||
| 
 | ||||
|   const [expandedKeys, setExpandedKeys] = useState<string[]>([]); | ||||
| 
 | ||||
|   const toggleExpandedState = (ruleKey: string) => | ||||
|     setExpandedKeys( | ||||
|       expandedKeys.includes(ruleKey) ? expandedKeys.filter((key) => key !== ruleKey) : [...expandedKeys, ruleKey] | ||||
|     ); | ||||
| 
 | ||||
|   return ( | ||||
|     <table className={cx(tableStyles.table, styles.table)}> | ||||
|       <colgroup> | ||||
|         <col className={styles.colExpand} /> | ||||
|         <col className={styles.colState} /> | ||||
|         <col /> | ||||
|         <col /> | ||||
|       </colgroup> | ||||
|       <thead> | ||||
|         <tr> | ||||
|           <th></th> | ||||
|           <th>State</th> | ||||
|           <th>Labels</th> | ||||
|           <th>Created</th> | ||||
|         </tr> | ||||
|       </thead> | ||||
|       <tbody> | ||||
|         {instances.map((instance, idx) => { | ||||
|           const key = alertInstanceKey(instance); | ||||
|           const isExpanded = expandedKeys.includes(key); | ||||
|           return ( | ||||
|             <Fragment key={key}> | ||||
|               <tr className={idx % 2 === 0 ? tableStyles.evenRow : undefined}> | ||||
|                 <td> | ||||
|                   <CollapseToggle | ||||
|                     isCollapsed={!isExpanded} | ||||
|                     onToggle={() => toggleExpandedState(key)} | ||||
|                     data-testid="alert-collapse-toggle" | ||||
|                   /> | ||||
|                 </td> | ||||
|                 <td> | ||||
|                   <StateTag status={instance.state} /> | ||||
|                 </td> | ||||
|                 <td className={styles.labelsCell}> | ||||
|                   <AlertLabels labels={instance.labels} /> | ||||
|                 </td> | ||||
|                 <td className={styles.createdCell}>{instance.activeAt.substr(0, 19).replace('T', ' ')}</td> | ||||
|               </tr> | ||||
|               {isExpanded && ( | ||||
|                 <tr className={idx % 2 === 0 ? tableStyles.evenRow : undefined}> | ||||
|                   <td></td> | ||||
|                   <td colSpan={3}> | ||||
|                     <AlertInstanceDetails instance={instance} /> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               )} | ||||
|             </Fragment> | ||||
|           ); | ||||
|         })} | ||||
|       </tbody> | ||||
|     </table> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   colExpand: css` | ||||
|     width: 36px; | ||||
|   `,
 | ||||
|   colState: css` | ||||
|     width: 110px; | ||||
|   `,
 | ||||
|   labelsCell: css` | ||||
|     padding-top: ${theme.spacing.xs} !important; | ||||
|     padding-bottom: ${theme.spacing.xs} !important; | ||||
|   `,
 | ||||
|   createdCell: css` | ||||
|     white-space: nowrap; | ||||
|   `,
 | ||||
|   table: css` | ||||
|     td { | ||||
|       vertical-align: top; | ||||
|       padding-top: ${theme.spacing.sm}; | ||||
|       padding-bottom: ${theme.spacing.sm}; | ||||
|     } | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,46 @@ | |||
| import React, { FC } from 'react'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| 
 | ||||
| interface Props { | ||||
|   label: React.ReactNode; | ||||
|   className?: string; | ||||
|   horizontal?: boolean; | ||||
| } | ||||
| 
 | ||||
| export const DetailsField: FC<Props> = ({ className, label, horizontal, children }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={cx(className, styles.field, horizontal ? styles.fieldHorizontal : styles.fieldVertical)}> | ||||
|       <div>{label}</div> | ||||
|       <div>{children}</div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   fieldHorizontal: css` | ||||
|     flex-direction: row; | ||||
|   `,
 | ||||
|   fieldVertical: css` | ||||
|     flex-direction: column; | ||||
|   `,
 | ||||
|   field: css` | ||||
|     display: flex; | ||||
|     margin: ${theme.spacing.md} 0; | ||||
| 
 | ||||
|     & > div:first-child { | ||||
|       width: 110px; | ||||
|       padding-right: ${theme.spacing.sm}; | ||||
|       font-size: ${theme.typography.size.sm}; | ||||
|       font-weight: ${theme.typography.weight.semibold}; | ||||
|       line-height: ${theme.typography.lineHeight.lg}; | ||||
|     } | ||||
|     & > div:nth-child(2) { | ||||
|       flex: 1; | ||||
|       color: ${theme.colors.textSemiWeak}; | ||||
|     } | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,15 @@ | |||
| import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; | ||||
| import React, { FC } from 'react'; | ||||
| 
 | ||||
| export const NoRulesSplash: FC = () => ( | ||||
|   <EmptyListCTA | ||||
|     title="You haven`t created any alert rules yet" | ||||
|     buttonIcon="bell" | ||||
|     buttonLink="/alerting/new" | ||||
|     buttonTitle="New alert rule" | ||||
|     proTip="you can also create alert rules from existing panels and queries." | ||||
|     proTipLink="https://grafana.com/docs/" | ||||
|     proTipLinkTitle="Learn more" | ||||
|     proTipTarget="_blank" | ||||
|   /> | ||||
| ); | ||||
|  | @ -0,0 +1,80 @@ | |||
| import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; | ||||
| import React, { FC } from 'react'; | ||||
| import { useStyles } from '@grafana/ui'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { RuleQuery } from '../RuleQuery'; | ||||
| import { isAlertingRule } from '../../utils/rules'; | ||||
| import { isCloudRulesSource } from '../../utils/datasource'; | ||||
| import { Annotation } from '../Annotation'; | ||||
| import { AlertLabels } from '../AlertLabels'; | ||||
| import { AlertInstancesTable } from './AlertInstancesTable'; | ||||
| import { DetailsField } from './DetailsField'; | ||||
| 
 | ||||
| interface Props { | ||||
|   rule: CombinedRule; | ||||
|   rulesSource: RulesSource; | ||||
| } | ||||
| 
 | ||||
| export const RuleDetails: FC<Props> = ({ rule, rulesSource }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   const { promRule } = rule; | ||||
| 
 | ||||
|   const annotations = Object.entries(rule.annotations); | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <div className={styles.wrapper}> | ||||
|         <div className={styles.leftSide}> | ||||
|           {!!rule.labels && !!Object.keys(rule.labels).length && ( | ||||
|             <DetailsField label="Labels" horizontal={true}> | ||||
|               <AlertLabels labels={rule.labels} /> | ||||
|             </DetailsField> | ||||
|           )} | ||||
|           <DetailsField label="Expression" className={cx({ [styles.exprRow]: !!annotations.length })} horizontal={true}> | ||||
|             <RuleQuery query={rule.query} rulesSource={rulesSource} /> | ||||
|           </DetailsField> | ||||
|           {annotations.map(([key, value]) => ( | ||||
|             <DetailsField key={key} label={key} horizontal={true}> | ||||
|               <Annotation annotationKey={key} value={value} /> | ||||
|             </DetailsField> | ||||
|           ))} | ||||
|         </div> | ||||
|         <div className={styles.rightSide}> | ||||
|           {isCloudRulesSource(rulesSource) && ( | ||||
|             <DetailsField label="Data source"> | ||||
|               <img className={styles.dataSourceIcon} src={rulesSource.meta.info.logos.small} /> {rulesSource.name} | ||||
|             </DetailsField> | ||||
|           )} | ||||
|         </div> | ||||
|       </div> | ||||
|       {promRule && isAlertingRule(promRule) && !!promRule.alerts?.length && ( | ||||
|         <DetailsField label="Matching instances" horizontal={true}> | ||||
|           <AlertInstancesTable instances={promRule.alerts} /> | ||||
|         </DetailsField> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   wrapper: css` | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|   `,
 | ||||
|   leftSide: css` | ||||
|     flex: 1; | ||||
|   `,
 | ||||
|   rightSide: css` | ||||
|     padding-left: 90px; | ||||
|     width: 300px; | ||||
|   `,
 | ||||
|   exprRow: css` | ||||
|     margin-bottom: 46px; | ||||
|   `,
 | ||||
|   dataSourceIcon: css` | ||||
|     width: ${theme.spacing.md}; | ||||
|     height: ${theme.spacing.md}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,168 @@ | |||
| import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting'; | ||||
| import React, { FC, useMemo, useState, Fragment } from 'react'; | ||||
| import { Icon, Tooltip, useStyles } from '@grafana/ui'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { css } from '@emotion/css'; | ||||
| import { isAlertingRule } from '../../utils/rules'; | ||||
| import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; | ||||
| import { StateColoredText } from '../StateColoredText'; | ||||
| import { CollapseToggle } from '../CollapseToggle'; | ||||
| import { RulesTable } from './RulesTable'; | ||||
| import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; | ||||
| import { ActionIcon } from './ActionIcon'; | ||||
| import pluralize from 'pluralize'; | ||||
| import { useHasRuler } from '../../hooks/useHasRuler'; | ||||
| interface Props { | ||||
|   namespace: string; | ||||
|   rulesSource: RulesSource; | ||||
|   group: CombinedRuleGroup; | ||||
| } | ||||
| 
 | ||||
| export const RulesGroup: FC<Props> = React.memo(({ group, namespace, rulesSource }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
| 
 | ||||
|   const [isCollapsed, setIsCollapsed] = useState(true); | ||||
| 
 | ||||
|   const hasRuler = useHasRuler(rulesSource); | ||||
| 
 | ||||
|   const stats = useMemo( | ||||
|     (): Record<PromAlertingRuleState, number> => | ||||
|       group.rules.reduce<Record<PromAlertingRuleState, number>>( | ||||
|         (stats, rule) => { | ||||
|           if (rule.promRule && isAlertingRule(rule.promRule)) { | ||||
|             stats[rule.promRule.state] += 1; | ||||
|           } | ||||
|           return stats; | ||||
|         }, | ||||
|         { | ||||
|           [PromAlertingRuleState.Firing]: 0, | ||||
|           [PromAlertingRuleState.Pending]: 0, | ||||
|           [PromAlertingRuleState.Inactive]: 0, | ||||
|         } | ||||
|       ), | ||||
|     [group] | ||||
|   ); | ||||
| 
 | ||||
|   const statsComponents: React.ReactNode[] = []; | ||||
|   if (stats[PromAlertingRuleState.Firing]) { | ||||
|     statsComponents.push( | ||||
|       <StateColoredText key="firing" status={PromAlertingRuleState.Firing}> | ||||
|         {stats[PromAlertingRuleState.Firing]} firing | ||||
|       </StateColoredText> | ||||
|     ); | ||||
|   } | ||||
|   if (stats[PromAlertingRuleState.Pending]) { | ||||
|     statsComponents.push( | ||||
|       <StateColoredText key="pending" status={PromAlertingRuleState.Pending}> | ||||
|         {stats[PromAlertingRuleState.Pending]} pending | ||||
|       </StateColoredText> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const actionIcons: React.ReactNode[] = []; | ||||
|   if (hasRuler) { | ||||
|     actionIcons.push(<ActionIcon key="edit" icon="pen" tooltip="edit" />); | ||||
|   } | ||||
|   if (rulesSource === GRAFANA_RULES_SOURCE_NAME) { | ||||
|     actionIcons.push(<ActionIcon key="manage-perms" icon="lock" tooltip="manage permissions" />); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.wrapper} data-testid="rule-group"> | ||||
|       <div className={styles.header} data-testid="rule-group-header"> | ||||
|         <CollapseToggle | ||||
|           className={styles.collapseToggle} | ||||
|           isCollapsed={isCollapsed} | ||||
|           onToggle={setIsCollapsed} | ||||
|           data-testid="group-collapse-toggle" | ||||
|         /> | ||||
|         <Icon name={isCollapsed ? 'folder-open' : 'folder'} /> | ||||
|         {isCloudRulesSource(rulesSource) && ( | ||||
|           <Tooltip content={rulesSource.name} placement="top"> | ||||
|             <img className={styles.dataSourceIcon} src={rulesSource.meta.info.logos.small} /> | ||||
|           </Tooltip> | ||||
|         )} | ||||
|         <h6 className={styles.heading}> | ||||
|           {namespace && `${namespace} > `} | ||||
|           {group.name} | ||||
|         </h6> | ||||
|         <div className={styles.spacer} /> | ||||
|         <div className={styles.headerStats}> | ||||
|           {group.rules.length} {pluralize('rule', group.rules.length)} | ||||
|           {!!statsComponents.length && ( | ||||
|             <> | ||||
|               :{' '} | ||||
|               {statsComponents.reduce<React.ReactNode[]>( | ||||
|                 (prev, curr, idx) => (prev.length ? [prev, <Fragment key={idx}>, </Fragment>, curr] : [curr]), | ||||
|                 [] | ||||
|               )} | ||||
|             </> | ||||
|           )} | ||||
|         </div> | ||||
|         {!!actionIcons.length && ( | ||||
|           <> | ||||
|             <div className={styles.actionsSeparator}>|</div> | ||||
|             <div className={styles.actionIcons}>{actionIcons}</div> | ||||
|           </> | ||||
|         )} | ||||
|       </div> | ||||
|       {!isCollapsed && <RulesTable rulesSource={rulesSource} namespace={namespace} group={group} />} | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
| 
 | ||||
| RulesGroup.displayName = 'RulesGroup'; | ||||
| 
 | ||||
| export const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   wrapper: css` | ||||
|     & + & { | ||||
|       margin-top: ${theme.spacing.md}; | ||||
|     } | ||||
|   `,
 | ||||
|   header: css` | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: center; | ||||
|     padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} 0; | ||||
|     background-color: ${theme.colors.bg2}; | ||||
|   `,
 | ||||
|   headerStats: css` | ||||
|     span { | ||||
|       vertical-align: middle; | ||||
|     } | ||||
|   `,
 | ||||
|   heading: css` | ||||
|     margin-left: ${theme.spacing.sm}; | ||||
|     margin-bottom: 0; | ||||
|   `,
 | ||||
|   spacer: css` | ||||
|     flex: 1; | ||||
|   `,
 | ||||
|   collapseToggle: css` | ||||
|     background: none; | ||||
|     border: none; | ||||
|     margin-top: -${theme.spacing.sm}; | ||||
|     margin-bottom: -${theme.spacing.sm}; | ||||
| 
 | ||||
|     svg { | ||||
|       margin-bottom: 0; | ||||
|     } | ||||
|   `,
 | ||||
|   dataSourceIcon: css` | ||||
|     width: ${theme.spacing.md}; | ||||
|     height: ${theme.spacing.md}; | ||||
|     margin-left: ${theme.spacing.md}; | ||||
|   `,
 | ||||
|   dataSourceOrigin: css` | ||||
|     margin-right: 1em; | ||||
|     color: ${theme.colors.textFaint}; | ||||
|   `,
 | ||||
|   actionsSeparator: css` | ||||
|     margin: 0 ${theme.spacing.sm}; | ||||
|   `,
 | ||||
|   actionIcons: css` | ||||
|     & > * + * { | ||||
|       margin-left: ${theme.spacing.sm}; | ||||
|     } | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,246 @@ | |||
| import { GrafanaTheme, rangeUtil } from '@grafana/data'; | ||||
| import { ConfirmModal, useStyles } from '@grafana/ui'; | ||||
| import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting'; | ||||
| import React, { FC, Fragment, useState } from 'react'; | ||||
| import { hashRulerRule, isAlertingRule } from '../../utils/rules'; | ||||
| import { CollapseToggle } from '../CollapseToggle'; | ||||
| import { css, cx } from '@emotion/css'; | ||||
| import { TimeToNow } from '../TimeToNow'; | ||||
| import { StateTag } from '../StateTag'; | ||||
| import { RuleDetails } from './RuleDetails'; | ||||
| import { getAlertTableStyles } from '../../styles/table'; | ||||
| import { ActionIcon } from './ActionIcon'; | ||||
| import { createExploreLink } from '../../utils/misc'; | ||||
| import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; | ||||
| import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; | ||||
| import { useDispatch } from 'react-redux'; | ||||
| import { deleteRuleAction } from '../../state/actions'; | ||||
| import { useHasRuler } from '../../hooks/useHasRuler'; | ||||
| 
 | ||||
| interface Props { | ||||
|   namespace: string; | ||||
|   group: CombinedRuleGroup; | ||||
|   rulesSource: RulesSource; | ||||
| } | ||||
| 
 | ||||
| export const RulesTable: FC<Props> = ({ group, rulesSource, namespace }) => { | ||||
|   const { rules } = group; | ||||
|   const dispatch = useDispatch(); | ||||
| 
 | ||||
|   const hasRuler = useHasRuler(rulesSource); | ||||
| 
 | ||||
|   const styles = useStyles(getStyles); | ||||
|   const tableStyles = useStyles(getAlertTableStyles); | ||||
| 
 | ||||
|   const [expandedKeys, setExpandedKeys] = useState<string[]>([]); | ||||
| 
 | ||||
|   const [ruleToDelete, setRuleToDelete] = useState<RulerRuleDTO>(); | ||||
| 
 | ||||
|   const toggleExpandedState = (ruleKey: string) => | ||||
|     setExpandedKeys( | ||||
|       expandedKeys.includes(ruleKey) ? expandedKeys.filter((key) => key !== ruleKey) : [...expandedKeys, ruleKey] | ||||
|     ); | ||||
| 
 | ||||
|   const deleteRule = () => { | ||||
|     if (ruleToDelete) { | ||||
|       dispatch( | ||||
|         deleteRuleAction({ | ||||
|           ruleSourceName: getRulesSourceName(rulesSource), | ||||
|           groupName: group.name, | ||||
|           namespace, | ||||
|           ruleHash: hashRulerRule(ruleToDelete), | ||||
|         }) | ||||
|       ); | ||||
|       setRuleToDelete(undefined); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   if (!rules.length) { | ||||
|     return <div className={styles.wrapper}>Folder is empty.</div>; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.wrapper}> | ||||
|       <table className={tableStyles.table} data-testid="rules-table"> | ||||
|         <colgroup> | ||||
|           <col className={styles.colExpand} /> | ||||
|           <col className={styles.colState} /> | ||||
|           <col /> | ||||
|           <col /> | ||||
|           <col /> | ||||
|           <col /> | ||||
|         </colgroup> | ||||
|         <thead> | ||||
|           <tr> | ||||
|             <th className={styles.relative}> | ||||
|               <div className={cx(styles.headerGuideline, styles.guideline)} /> | ||||
|             </th> | ||||
|             <th>State</th> | ||||
|             <th>Name</th> | ||||
|             <th>Status</th> | ||||
|             <th>Evaluation</th> | ||||
|             <th>Actions</th> | ||||
|           </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|           {(() => { | ||||
|             const seenKeys: string[] = []; | ||||
|             return rules.map((rule, ruleIdx) => { | ||||
|               let key = JSON.stringify([rule.promRule?.type, rule.labels, rule.query, rule.name, rule.annotations]); | ||||
|               if (seenKeys.includes(key)) { | ||||
|                 key += `-${ruleIdx}`; | ||||
|               } | ||||
|               seenKeys.push(key); | ||||
|               const isExpanded = expandedKeys.includes(key); | ||||
|               const { promRule, rulerRule } = rule; | ||||
|               const statuses = [ | ||||
|                 promRule?.health, | ||||
|                 hasRuler && promRule && !rulerRule ? 'deleting' : '', | ||||
|                 hasRuler && rulerRule && !promRule ? 'creating' : '', | ||||
|               ].filter((x) => !!x); | ||||
|               return ( | ||||
|                 <Fragment key={key}> | ||||
|                   <tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}> | ||||
|                     <td className={styles.relative}> | ||||
|                       <div className={cx(styles.ruleTopGuideline, styles.guideline)} /> | ||||
|                       {!(ruleIdx === rules.length - 1) && ( | ||||
|                         <div className={cx(styles.ruleBottomGuideline, styles.guideline)} /> | ||||
|                       )} | ||||
|                       <CollapseToggle | ||||
|                         isCollapsed={!isExpanded} | ||||
|                         onToggle={() => toggleExpandedState(key)} | ||||
|                         data-testid="rule-collapse-toggle" | ||||
|                       /> | ||||
|                     </td> | ||||
|                     <td>{promRule && isAlertingRule(promRule) ? <StateTag status={promRule.state} /> : 'n/a'}</td> | ||||
|                     <td>{rule.name}</td> | ||||
|                     <td>{statuses.join(', ') || 'n/a'}</td> | ||||
|                     <td> | ||||
|                       {promRule?.lastEvaluation && promRule.evaluationTime ? ( | ||||
|                         <> | ||||
|                           <TimeToNow date={promRule.lastEvaluation} />, for{' '} | ||||
|                           {rangeUtil.secondsToHms(promRule.evaluationTime)} | ||||
|                         </> | ||||
|                       ) : ( | ||||
|                         'n/a' | ||||
|                       )} | ||||
|                     </td> | ||||
|                     <td className={styles.actionsCell}> | ||||
|                       {isCloudRulesSource(rulesSource) && ( | ||||
|                         <ActionIcon | ||||
|                           icon="compass" | ||||
|                           tooltip="view in explore" | ||||
|                           target="__blank" | ||||
|                           href={createExploreLink(rulesSource.name, rule.query)} | ||||
|                         /> | ||||
|                       )} | ||||
|                       {!!rulerRule && <ActionIcon icon="pen" tooltip="edit rule" />} | ||||
|                       {!!rulerRule && ( | ||||
|                         <ActionIcon icon="trash-alt" tooltip="delete rule" onClick={() => setRuleToDelete(rulerRule)} /> | ||||
|                       )} | ||||
|                     </td> | ||||
|                   </tr> | ||||
|                   {isExpanded && ( | ||||
|                     <tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}> | ||||
|                       <td className={styles.relative}> | ||||
|                         {!(ruleIdx === rules.length - 1) && ( | ||||
|                           <div className={cx(styles.ruleContentGuideline, styles.guideline)} /> | ||||
|                         )} | ||||
|                       </td> | ||||
|                       <td colSpan={5}> | ||||
|                         <RuleDetails rulesSource={rulesSource} rule={rule} /> | ||||
|                       </td> | ||||
|                     </tr> | ||||
|                   )} | ||||
|                 </Fragment> | ||||
|               ); | ||||
|             }); | ||||
|           })()} | ||||
|         </tbody> | ||||
|       </table> | ||||
|       {!!ruleToDelete && ( | ||||
|         <ConfirmModal | ||||
|           isOpen={true} | ||||
|           title="Delete rule" | ||||
|           body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?" | ||||
|           confirmText="Yes, delete" | ||||
|           icon="exclamation-triangle" | ||||
|           onConfirm={deleteRule} | ||||
|           onDismiss={() => setRuleToDelete(undefined)} | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   wrapper: css` | ||||
|     margin-top: ${theme.spacing.md}; | ||||
|     margin-left: 36px; | ||||
|     width: auto; | ||||
|     padding: ${theme.spacing.sm}; | ||||
|     background-color: ${theme.colors.bg2}; | ||||
|     border-radius: 3px; | ||||
|   `,
 | ||||
|   table: css` | ||||
|     width: 100%; | ||||
|     border-radius: 3px; | ||||
|     border: solid 1px ${theme.colors.border3}; | ||||
| 
 | ||||
|     th { | ||||
|       padding: ${theme.spacing.sm}; | ||||
|     } | ||||
| 
 | ||||
|     td + td { | ||||
|       padding: 0 ${theme.spacing.sm}; | ||||
|     } | ||||
| 
 | ||||
|     tr { | ||||
|       height: 38px; | ||||
|     } | ||||
|   `,
 | ||||
|   evenRow: css` | ||||
|     background-color: ${theme.colors.bodyBg}; | ||||
|   `,
 | ||||
|   colExpand: css` | ||||
|     width: 36px; | ||||
|   `,
 | ||||
|   colState: css` | ||||
|     width: 110px; | ||||
|   `,
 | ||||
|   relative: css` | ||||
|     position: relative; | ||||
|   `,
 | ||||
|   guideline: css` | ||||
|     left: -27px; | ||||
|     border-left: 1px solid ${theme.colors.border3}; | ||||
|     position: absolute; | ||||
|   `,
 | ||||
|   ruleTopGuideline: css` | ||||
|     width: 18px; | ||||
|     border-bottom: 1px solid ${theme.colors.border3}; | ||||
|     top: 0; | ||||
|     bottom: 50%; | ||||
|   `,
 | ||||
|   ruleBottomGuideline: css` | ||||
|     top: 50%; | ||||
|     bottom: 0; | ||||
|   `,
 | ||||
|   ruleContentGuideline: css` | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|   `,
 | ||||
|   headerGuideline: css` | ||||
|     top: -24px; | ||||
|     bottom: 0; | ||||
|   `,
 | ||||
|   actionsCell: css` | ||||
|     text-align: right; | ||||
|     width: 1%; | ||||
|     white-space: nowrap; | ||||
| 
 | ||||
|     & > * + * { | ||||
|       margin-left: ${theme.spacing.sm}; | ||||
|     } | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,66 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { LoadingPlaceholder, useStyles } from '@grafana/ui'; | ||||
| import React, { FC, useMemo } from 'react'; | ||||
| import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; | ||||
| import { RulesGroup } from './RulesGroup'; | ||||
| import { getRulesDataSources, getRulesSourceName } from '../../utils/datasource'; | ||||
| import { CombinedRuleNamespace } from 'app/types/unified-alerting'; | ||||
| import pluralize from 'pluralize'; | ||||
| 
 | ||||
| interface Props { | ||||
|   namespaces: CombinedRuleNamespace[]; | ||||
| } | ||||
| 
 | ||||
| export const SystemOrApplicationRules: FC<Props> = ({ namespaces }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
|   const rules = useUnifiedAlertingSelector((state) => state.promRules); | ||||
|   const rulesDataSources = useMemo(getRulesDataSources, []); | ||||
| 
 | ||||
|   const dataSourcesLoading = useMemo(() => rulesDataSources.filter((ds) => rules[ds.name]?.loading), [ | ||||
|     rules, | ||||
|     rulesDataSources, | ||||
|   ]); | ||||
| 
 | ||||
|   return ( | ||||
|     <section className={styles.wrapper}> | ||||
|       <div className={styles.sectionHeader}> | ||||
|         <h5>System or application</h5> | ||||
|         {dataSourcesLoading.length ? ( | ||||
|           <LoadingPlaceholder | ||||
|             className={styles.loader} | ||||
|             text={`Loading rules from ${dataSourcesLoading.length} ${pluralize('source', dataSourcesLoading.length)}`} | ||||
|           /> | ||||
|         ) : ( | ||||
|           <div /> | ||||
|         )} | ||||
|       </div> | ||||
| 
 | ||||
|       {namespaces.map(({ rulesSource, name, groups }) => | ||||
|         groups.map((group) => ( | ||||
|           <RulesGroup | ||||
|             group={group} | ||||
|             key={`${getRulesSourceName(rulesSource)}-${name}-${group.name}`} | ||||
|             namespace={name} | ||||
|             rulesSource={rulesSource} | ||||
|           /> | ||||
|         )) | ||||
|       )} | ||||
|       {namespaces?.length === 0 && !!rulesDataSources.length && <p>No rules found.</p>} | ||||
|       {!rulesDataSources.length && <p>There are no Prometheus or Loki datas sources configured.</p>} | ||||
|     </section> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   loader: css` | ||||
|     margin-bottom: 0; | ||||
|   `,
 | ||||
|   sectionHeader: css` | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|   `,
 | ||||
|   wrapper: css` | ||||
|     margin-bottom: ${theme.spacing.xl}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,54 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { LoadingPlaceholder, useStyles } from '@grafana/ui'; | ||||
| import React, { FC } from 'react'; | ||||
| import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; | ||||
| import { RulesGroup } from './RulesGroup'; | ||||
| import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; | ||||
| import { CombinedRuleNamespace } from 'app/types/unified-alerting'; | ||||
| import { initialAsyncRequestState } from '../../utils/redux'; | ||||
| 
 | ||||
| interface Props { | ||||
|   namespaces: CombinedRuleNamespace[]; | ||||
| } | ||||
| 
 | ||||
| export const ThresholdRules: FC<Props> = ({ namespaces }) => { | ||||
|   const styles = useStyles(getStyles); | ||||
|   const { loading } = useUnifiedAlertingSelector( | ||||
|     (state) => state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <section className={styles.wrapper}> | ||||
|       <div className={styles.sectionHeader}> | ||||
|         <h5>Threshold</h5> | ||||
|         {loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />} | ||||
|       </div> | ||||
| 
 | ||||
|       {namespaces?.map((namespace) => | ||||
|         namespace.groups.map((group) => ( | ||||
|           <RulesGroup | ||||
|             group={group} | ||||
|             key={`${namespace.name}-${group.name}`} | ||||
|             namespace={namespace.name} | ||||
|             rulesSource={GRAFANA_RULES_SOURCE_NAME} | ||||
|           /> | ||||
|         )) | ||||
|       )} | ||||
|       {namespaces?.length === 0 && <p>No rules found.</p>} | ||||
|     </section> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme) => ({ | ||||
|   loader: css` | ||||
|     margin-bottom: 0; | ||||
|   `,
 | ||||
|   sectionHeader: css` | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|   `,
 | ||||
|   wrapper: css` | ||||
|     margin-bottom: ${theme.spacing.xl}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,44 @@ | |||
| import { useQueryParams } from 'app/core/hooks/useQueryParams'; | ||||
| import store from 'app/core/store'; | ||||
| import { useCallback } from 'react'; | ||||
| import { getAlertManagerDataSources, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; | ||||
| 
 | ||||
| const alertmanagerQueryKey = 'alertmanager'; | ||||
| const alertmanagerLocalStorageKey = 'alerting-alertmanager'; | ||||
| 
 | ||||
| function isAlertManagerSource(alertManagerSourceName: string): boolean { | ||||
|   return ( | ||||
|     alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME || | ||||
|     !!getAlertManagerDataSources().find((ds) => ds.name === alertManagerSourceName) | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| /* this will return am name either from query params or from local storage or a default (grafana). | ||||
|  * it might makes sense to abstract to more generic impl.. | ||||
|  */ | ||||
| export function useAlertManagerSourceName(): [string, (alertManagerSourceName: string) => void] { | ||||
|   const [queryParams, updateQueryParams] = useQueryParams(); | ||||
| 
 | ||||
|   const update = useCallback( | ||||
|     (alertManagerSourceName: string) => { | ||||
|       if (isAlertManagerSource(alertManagerSourceName)) { | ||||
|         store.set(alertmanagerLocalStorageKey, alertManagerSourceName); | ||||
|         updateQueryParams({ [alertmanagerQueryKey]: alertManagerSourceName }); | ||||
|       } | ||||
|     }, | ||||
|     [updateQueryParams] | ||||
|   ); | ||||
| 
 | ||||
|   const querySource = queryParams[alertmanagerQueryKey]; | ||||
| 
 | ||||
|   if (querySource && typeof querySource === 'string' && isAlertManagerSource(querySource)) { | ||||
|     return [querySource, update]; | ||||
|   } | ||||
|   const storeSource = store.get(alertmanagerLocalStorageKey); | ||||
|   if (storeSource && typeof storeSource === 'string' && isAlertManagerSource(storeSource)) { | ||||
|     update(storeSource); | ||||
|     return [storeSource, update]; | ||||
|   } | ||||
| 
 | ||||
|   return [GRAFANA_RULES_SOURCE_NAME, update]; | ||||
| } | ||||
|  | @ -0,0 +1,130 @@ | |||
| import { CombinedRule, CombinedRuleNamespace, Rule, RuleNamespace } from 'app/types/unified-alerting'; | ||||
| import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; | ||||
| import { useMemo, useRef } from 'react'; | ||||
| import { getAllRulesSources, isCloudRulesSource } from '../utils/datasource'; | ||||
| import { isAlertingRule, isAlertingRulerRule } from '../utils/rules'; | ||||
| import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; | ||||
| 
 | ||||
| interface CacheValue { | ||||
|   promRules?: RuleNamespace[]; | ||||
|   rulerRules?: RulerRulesConfigDTO | null; | ||||
|   result: CombinedRuleNamespace[]; | ||||
| } | ||||
| 
 | ||||
| // this little monster combines prometheus rules and ruler rules to produce a unfied data structure
 | ||||
| export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] { | ||||
|   const promRulesResponses = useUnifiedAlertingSelector((state) => state.promRules); | ||||
|   const rulerRulesResponses = useUnifiedAlertingSelector((state) => state.rulerRules); | ||||
| 
 | ||||
|   // cache results per rules source, so we only recalculate those for which results have actually changed
 | ||||
|   const cache = useRef<Record<string, CacheValue>>({}); | ||||
| 
 | ||||
|   return useMemo(() => { | ||||
|     const retv = getAllRulesSources() | ||||
|       .map((rulesSource): CombinedRuleNamespace[] => { | ||||
|         const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource; | ||||
|         const promRules = promRulesResponses[rulesSourceName]?.result; | ||||
|         const rulerRules = rulerRulesResponses[rulesSourceName]?.result; | ||||
| 
 | ||||
|         const cached = cache.current[rulesSourceName]; | ||||
|         if (cached && cached.promRules === promRules && cached.rulerRules === rulerRules) { | ||||
|           return cached.result; | ||||
|         } | ||||
|         const namespaces: Record<string, CombinedRuleNamespace> = {}; | ||||
| 
 | ||||
|         // first get all the ruler rules in
 | ||||
|         Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => { | ||||
|           namespaces[namespaceName] = { | ||||
|             rulesSource, | ||||
|             name: namespaceName, | ||||
|             groups: groups.map((group) => ({ | ||||
|               name: group.name, | ||||
|               rules: group.rules.map( | ||||
|                 (rule): CombinedRule => | ||||
|                   isAlertingRulerRule(rule) | ||||
|                     ? { | ||||
|                         name: rule.alert, | ||||
|                         query: rule.expr, | ||||
|                         labels: rule.labels || {}, | ||||
|                         annotations: rule.annotations || {}, | ||||
|                         rulerRule: rule, | ||||
|                       } | ||||
|                     : { | ||||
|                         name: rule.record, | ||||
|                         query: rule.expr, | ||||
|                         labels: rule.labels || {}, | ||||
|                         annotations: {}, | ||||
|                         rulerRule: rule, | ||||
|                       } | ||||
|               ), | ||||
|             })), | ||||
|           }; | ||||
|         }); | ||||
| 
 | ||||
|         // then correlate with prometheus rules
 | ||||
|         promRules?.forEach(({ name: namespaceName, groups }) => { | ||||
|           const ns = (namespaces[namespaceName] = namespaces[namespaceName] || { | ||||
|             rulesSource, | ||||
|             name: namespaceName, | ||||
|             groups: [], | ||||
|           }); | ||||
| 
 | ||||
|           groups.forEach((group) => { | ||||
|             let combinedGroup = ns.groups.find((g) => g.name === group.name); | ||||
|             if (!combinedGroup) { | ||||
|               combinedGroup = { | ||||
|                 name: group.name, | ||||
|                 rules: [], | ||||
|               }; | ||||
|               ns.groups.push(combinedGroup); | ||||
|             } | ||||
| 
 | ||||
|             group.rules.forEach((rule) => { | ||||
|               const existingRule = combinedGroup!.rules.find((existingRule) => { | ||||
|                 return !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule); | ||||
|               }); | ||||
|               if (existingRule) { | ||||
|                 existingRule.promRule = rule; | ||||
|               } else { | ||||
|                 combinedGroup!.rules.push({ | ||||
|                   name: rule.name, | ||||
|                   query: rule.query, | ||||
|                   labels: rule.labels || {}, | ||||
|                   annotations: isAlertingRule(rule) ? rule.annotations || {} : {}, | ||||
|                   promRule: rule, | ||||
|                 }); | ||||
|               } | ||||
|             }); | ||||
|           }); | ||||
|         }); | ||||
| 
 | ||||
|         const result = Object.values(namespaces); | ||||
|         cache.current[rulesSourceName] = { promRules, rulerRules, result }; | ||||
|         return result; | ||||
|       }) | ||||
|       .flat(); | ||||
|     return retv; | ||||
|   }, [promRulesResponses, rulerRulesResponses]); | ||||
| } | ||||
| 
 | ||||
| function isCombinedRuleEqualToPromRule(combinedRule: CombinedRule, rule: Rule): boolean { | ||||
|   if (combinedRule.name === rule.name) { | ||||
|     return ( | ||||
|       JSON.stringify([hashQuery(combinedRule.query), combinedRule.labels, combinedRule.annotations]) === | ||||
|       JSON.stringify([hashQuery(rule.query), rule.labels || {}, isAlertingRule(rule) ? rule.annotations || {} : {}]) | ||||
|     ); | ||||
|   } | ||||
|   return false; | ||||
| } | ||||
| 
 | ||||
| // there can be slight differences in how prom & ruler render a query, this will hash them accounting for the differences
 | ||||
| function hashQuery(query: string) { | ||||
|   // one of them might be wrapped in parens
 | ||||
|   if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') { | ||||
|     query = query.substr(1, query.length - 2); | ||||
|   } | ||||
|   // whitespace could be added or removed
 | ||||
|   query = query.replace(/\s|\n/g, ''); | ||||
|   // labels matchers can be reordered, so sort the enitre string, esentially comparing just hte character counts
 | ||||
|   return query.split('').sort().join(''); | ||||
| } | ||||
|  | @ -0,0 +1,10 @@ | |||
| import { RulesSource } from 'app/types/unified-alerting'; | ||||
| import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; | ||||
| import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; | ||||
| 
 | ||||
| // datasource has ruler if it's grafana managed or if we're able to load rules from it
 | ||||
| export function useHasRuler(rulesSource: string | RulesSource): boolean { | ||||
|   const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules); | ||||
|   const rulesSourceName = typeof rulesSource === 'string' ? rulesSource : rulesSource.name; | ||||
|   return rulesSourceName === GRAFANA_RULES_SOURCE_NAME || !!rulerRules[rulesSourceName]?.result; | ||||
| } | ||||
|  | @ -0,0 +1,10 @@ | |||
| import { StoreState } from 'app/types'; | ||||
| import { useSelector } from 'react-redux'; | ||||
| import { UnifiedAlertingState } from '../state/reducers'; | ||||
| 
 | ||||
| export function useUnifiedAlertingSelector<TSelected = unknown>( | ||||
|   selector: (state: UnifiedAlertingState) => TSelected, | ||||
|   equalityFn?: (left: TSelected, right: TSelected) => boolean | ||||
| ): TSelected { | ||||
|   return useSelector((state: StoreState) => selector(state.unifiedAlerting), equalityFn); | ||||
| } | ||||
|  | @ -0,0 +1,93 @@ | |||
| import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data'; | ||||
| import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; | ||||
| import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting'; | ||||
| 
 | ||||
| let nextDataSourceId = 1; | ||||
| 
 | ||||
| export const mockDataSource = (partial: Partial<DataSourceInstanceSettings> = {}): DataSourceInstanceSettings => { | ||||
|   const id = partial.id ?? nextDataSourceId++; | ||||
| 
 | ||||
|   return { | ||||
|     id, | ||||
|     uid: `mock-ds-${nextDataSourceId}`, | ||||
|     type: 'prometheus', | ||||
|     name: `Prometheus-${id}`, | ||||
|     jsonData: {}, | ||||
|     meta: ({ | ||||
|       info: { | ||||
|         logos: { | ||||
|           small: 'https://prometheus.io/assets/prometheus_logo_grey.svg', | ||||
|           large: 'https://prometheus.io/assets/prometheus_logo_grey.svg', | ||||
|         }, | ||||
|       }, | ||||
|     } as any) as DataSourcePluginMeta, | ||||
|     ...partial, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export const mockPromAlert = (partial: Partial<Alert> = {}): Alert => ({ | ||||
|   activeAt: '2021-03-18T13:47:05.04938691Z', | ||||
|   annotations: { | ||||
|     message: 'alert with severity "warning"', | ||||
|   }, | ||||
|   labels: { | ||||
|     alertname: 'myalert', | ||||
|     severity: 'warning', | ||||
|   }, | ||||
|   state: PromAlertingRuleState.Firing, | ||||
|   value: '1e+00', | ||||
|   ...partial, | ||||
| }); | ||||
| 
 | ||||
| export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): AlertingRule => { | ||||
|   return { | ||||
|     type: PromRuleType.Alerting, | ||||
|     alerts: [mockPromAlert()], | ||||
|     name: 'myalert', | ||||
|     query: 'foo > 1', | ||||
|     lastEvaluation: '2021-03-23T08:19:05.049595312Z', | ||||
|     evaluationTime: 0.000395601, | ||||
|     annotations: { | ||||
|       message: 'alert with severity "{{.warning}}}"', | ||||
|     }, | ||||
|     labels: { | ||||
|       severity: 'warning', | ||||
|     }, | ||||
|     state: PromAlertingRuleState.Firing, | ||||
|     health: 'OK', | ||||
|     ...partial, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export const mockPromRecordingRule = (partial: Partial<RecordingRule> = {}): RecordingRule => { | ||||
|   return { | ||||
|     type: PromRuleType.Recording, | ||||
|     query: 'bar < 3', | ||||
|     labels: { | ||||
|       cluster: 'eu-central', | ||||
|     }, | ||||
|     health: 'OK', | ||||
|     name: 'myrecordingrule', | ||||
|     lastEvaluation: '2021-03-23T08:19:05.049595312Z', | ||||
|     evaluationTime: 0.000395601, | ||||
|     ...partial, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export const mockPromRuleGroup = (partial: Partial<RuleGroup> = {}): RuleGroup => { | ||||
|   return { | ||||
|     name: 'mygroup', | ||||
|     interval: 60, | ||||
|     rules: [mockPromAlertingRule()], | ||||
|     ...partial, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export const mockPromRuleNamespace = (partial: Partial<RuleNamespace> = {}): RuleNamespace => { | ||||
|   return { | ||||
|     dataSourceName: 'Prometheus-1', | ||||
|     name: 'default', | ||||
|     groups: [mockPromRuleGroup()], | ||||
|     ...partial, | ||||
|   }; | ||||
| }; | ||||
|  | @ -0,0 +1,73 @@ | |||
| import { createAsyncThunk } from '@reduxjs/toolkit'; | ||||
| import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; | ||||
| import { ThunkResult } from 'app/types'; | ||||
| import { RuleLocation, RuleNamespace } from 'app/types/unified-alerting'; | ||||
| import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; | ||||
| import { fetchAlertManagerConfig } from '../api/alertmanager'; | ||||
| import { fetchRules } from '../api/prometheus'; | ||||
| import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesNamespace, setRulerRuleGroup } from '../api/ruler'; | ||||
| import { getAllRulesSourceNames, isCloudRulesSource } from '../utils/datasource'; | ||||
| import { withSerializedError } from '../utils/redux'; | ||||
| import { hashRulerRule } from '../utils/rules'; | ||||
| 
 | ||||
| export const fetchPromRulesAction = createAsyncThunk( | ||||
|   'unifiedalerting/fetchPromRules', | ||||
|   (rulesSourceName: string): Promise<RuleNamespace[]> => withSerializedError(fetchRules(rulesSourceName)) | ||||
| ); | ||||
| 
 | ||||
| export const fetchAlertManagerConfigAction = createAsyncThunk( | ||||
|   'unifiedalerting/fetchAmConfig', | ||||
|   (alertManagerSourceName: string): Promise<AlertManagerCortexConfig> => | ||||
|     withSerializedError(fetchAlertManagerConfig(alertManagerSourceName)) | ||||
| ); | ||||
| 
 | ||||
| export const fetchRulerRulesAction = createAsyncThunk( | ||||
|   'unifiedalerting/fetchRulerRules', | ||||
|   (rulesSourceName: string): Promise<RulerRulesConfigDTO | null> => { | ||||
|     return withSerializedError(fetchRulerRules(rulesSourceName)); | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| export function fetchAllPromAndRulerRules(force = false): ThunkResult<void> { | ||||
|   return (dispatch, getStore) => { | ||||
|     const { promRules, rulerRules } = getStore().unifiedAlerting; | ||||
|     getAllRulesSourceNames().map((name) => { | ||||
|       if (force || !promRules[name]?.loading) { | ||||
|         dispatch(fetchPromRulesAction(name)); | ||||
|       } | ||||
|       if (force || !rulerRules[name]?.loading) { | ||||
|         dispatch(fetchRulerRulesAction(name)); | ||||
|       } | ||||
|     }); | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function deleteRuleAction(ruleLocation: RuleLocation): ThunkResult<void> { | ||||
|   /* | ||||
|    * fetch the rules group from backend, delete group if it is found and+ | ||||
|    * reload ruler rules | ||||
|    */ | ||||
|   return async (dispatch) => { | ||||
|     const { namespace, groupName, ruleSourceName, ruleHash } = ruleLocation; | ||||
|     //const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName);
 | ||||
|     const groups = await fetchRulerRulesNamespace(ruleSourceName, namespace); | ||||
|     const group = groups.find((group) => group.name === groupName); | ||||
|     if (!group) { | ||||
|       throw new Error('Failed to delete rule: group not found.'); | ||||
|     } | ||||
|     const existingRule = group.rules.find((rule) => hashRulerRule(rule) === ruleHash); | ||||
|     if (!existingRule) { | ||||
|       throw new Error('Failed to delete rule: group not found.'); | ||||
|     } | ||||
|     // for cloud datasources, delete group if this rule is the last rule
 | ||||
|     if (group.rules.length === 1 && isCloudRulesSource(ruleSourceName)) { | ||||
|       await deleteRulerRulesGroup(ruleSourceName, namespace, groupName); | ||||
|     } else { | ||||
|       await setRulerRuleGroup(ruleSourceName, namespace, { | ||||
|         ...group, | ||||
|         rules: group.rules.filter((rule) => rule !== existingRule), | ||||
|       }); | ||||
|     } | ||||
|     return dispatch(fetchRulerRulesAction(ruleSourceName)); | ||||
|   }; | ||||
| } | ||||
|  | @ -0,0 +1,17 @@ | |||
| import { combineReducers } from 'redux'; | ||||
| import { createAsyncMapSlice } from '../utils/redux'; | ||||
| import { fetchAlertManagerConfigAction, fetchPromRulesAction, fetchRulerRulesAction } from './actions'; | ||||
| 
 | ||||
| export const reducer = combineReducers({ | ||||
|   promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, (dataSourceName) => dataSourceName).reducer, | ||||
|   rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, (dataSourceName) => dataSourceName).reducer, | ||||
|   amConfigs: createAsyncMapSlice( | ||||
|     'amConfigs', | ||||
|     fetchAlertManagerConfigAction, | ||||
|     (alertManagerSourceName) => alertManagerSourceName | ||||
|   ).reducer, | ||||
| }); | ||||
| 
 | ||||
| export type UnifiedAlertingState = ReturnType<typeof reducer>; | ||||
| 
 | ||||
| export default reducer; | ||||
|  | @ -0,0 +1,26 @@ | |||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { css } from '@emotion/css'; | ||||
| 
 | ||||
| export const getAlertTableStyles = (theme: GrafanaTheme) => ({ | ||||
|   table: css` | ||||
|     width: 100%; | ||||
|     border-radius: ${theme.border.radius.sm}; | ||||
|     border: solid 1px ${theme.colors.border3}; | ||||
|     background-color: ${theme.colors.bg2}; | ||||
| 
 | ||||
|     th { | ||||
|       padding: ${theme.spacing.sm}; | ||||
|     } | ||||
| 
 | ||||
|     td { | ||||
|       padding: 0 ${theme.spacing.sm}; | ||||
|     } | ||||
| 
 | ||||
|     tr { | ||||
|       height: 38px; | ||||
|     } | ||||
|   `,
 | ||||
|   evenRow: css` | ||||
|     background-color: ${theme.colors.bodyBg}; | ||||
|   `,
 | ||||
| }); | ||||
|  | @ -0,0 +1,6 @@ | |||
| import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| 
 | ||||
| export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> { | ||||
|   return Object.values(config.datasources); | ||||
| } | ||||
|  | @ -0,0 +1,3 @@ | |||
| export const RULER_NOT_SUPPORTED_MSG = 'ruler not supported'; | ||||
| 
 | ||||
| export const RULE_LIST_POLL_INTERVAL_MS = 20000; | ||||
|  | @ -0,0 +1,67 @@ | |||
| import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; | ||||
| import { RulesSource } from 'app/types/unified-alerting'; | ||||
| import { getAllDataSources } from './config'; | ||||
| 
 | ||||
| export const GRAFANA_RULES_SOURCE_NAME = 'grafana'; | ||||
| 
 | ||||
| export enum DataSourceType { | ||||
|   Alertmanager = 'alertmanager', | ||||
|   Loki = 'loki', | ||||
|   Prometheus = 'prometheus', | ||||
| } | ||||
| 
 | ||||
| export const RulesDataSourceTypes: string[] = [DataSourceType.Loki, DataSourceType.Prometheus]; | ||||
| 
 | ||||
| export function getRulesDataSources() { | ||||
|   return getAllDataSources() | ||||
|     .filter((ds) => RulesDataSourceTypes.includes(ds.type)) | ||||
|     .sort((a, b) => a.name.localeCompare(b.name)); | ||||
| } | ||||
| 
 | ||||
| export function getAlertManagerDataSources() { | ||||
|   return getAllDataSources() | ||||
|     .filter((ds) => ds.type === DataSourceType.Alertmanager) | ||||
|     .sort((a, b) => a.name.localeCompare(b.name)); | ||||
| } | ||||
| 
 | ||||
| export function getLotexDataSourceByName(dataSourceName: string): DataSourceInstanceSettings { | ||||
|   const dataSource = getDataSourceByName(dataSourceName); | ||||
|   if (!dataSource) { | ||||
|     throw new Error(`Data source ${dataSourceName} not found`); | ||||
|   } | ||||
|   if (dataSource.type !== DataSourceType.Loki && dataSource.type !== DataSourceType.Prometheus) { | ||||
|     throw new Error(`Unexpected data source type ${dataSource.type}`); | ||||
|   } | ||||
|   return dataSource; | ||||
| } | ||||
| 
 | ||||
| export function getAllRulesSourceNames(): string[] { | ||||
|   return [...getRulesDataSources().map((r) => r.name), GRAFANA_RULES_SOURCE_NAME]; | ||||
| } | ||||
| 
 | ||||
| export function getAllRulesSources(): RulesSource[] { | ||||
|   return [...getRulesDataSources(), GRAFANA_RULES_SOURCE_NAME]; | ||||
| } | ||||
| 
 | ||||
| export function getRulesSourceName(rulesSource: RulesSource): string { | ||||
|   return isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource; | ||||
| } | ||||
| 
 | ||||
| export function isCloudRulesSource(rulesSource: RulesSource | string): rulesSource is DataSourceInstanceSettings { | ||||
|   return rulesSource !== GRAFANA_RULES_SOURCE_NAME; | ||||
| } | ||||
| 
 | ||||
| export function getDataSourceByName(name: string): DataSourceInstanceSettings<DataSourceJsonData> | undefined { | ||||
|   return getAllDataSources().find((source) => source.name === name); | ||||
| } | ||||
| 
 | ||||
| export function getDatasourceAPIId(dataSourceName: string) { | ||||
|   if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { | ||||
|     return GRAFANA_RULES_SOURCE_NAME; | ||||
|   } | ||||
|   const ds = getDataSourceByName(dataSourceName); | ||||
|   if (!ds) { | ||||
|     throw new Error(`Datasource "${dataSourceName}" not found`); | ||||
|   } | ||||
|   return String(ds.id); | ||||
| } | ||||
|  | @ -0,0 +1,28 @@ | |||
| import { config } from '@grafana/runtime'; | ||||
| import { urlUtil } from '@grafana/data'; | ||||
| 
 | ||||
| export function createExploreLink(dataSourceName: string, query: string) { | ||||
|   return urlUtil.renderUrl(config.appSubUrl + '/explore', { | ||||
|     left: JSON.stringify([ | ||||
|       'now-1h', | ||||
|       'now', | ||||
|       dataSourceName, | ||||
|       { datasource: dataSourceName, expr: query }, | ||||
|       { ui: [true, true, true, 'none'] }, | ||||
|     ]), | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| // used to hash rules
 | ||||
| export function hash(value: string): number { | ||||
|   let hash = 0; | ||||
|   if (value.length === 0) { | ||||
|     return hash; | ||||
|   } | ||||
|   for (var i = 0; i < value.length; i++) { | ||||
|     var char = value.charCodeAt(i); | ||||
|     hash = (hash << 5) - hash + char; | ||||
|     hash = hash & hash; // Convert to 32bit integer
 | ||||
|   } | ||||
|   return hash; | ||||
| } | ||||
|  | @ -0,0 +1,106 @@ | |||
| import { AnyAction, AsyncThunk, createSlice, Draft, isAsyncThunkAction, SerializedError } from '@reduxjs/toolkit'; | ||||
| 
 | ||||
| export interface AsyncRequestState<T> { | ||||
|   result?: T; | ||||
|   loading: boolean; | ||||
|   error?: SerializedError; | ||||
|   dispatched: boolean; | ||||
|   requestId?: string; | ||||
| } | ||||
| 
 | ||||
| export const initialAsyncRequestState: AsyncRequestState<any> = Object.freeze({ | ||||
|   loading: false, | ||||
|   dispatched: false, | ||||
| }); | ||||
| 
 | ||||
| export type AsyncRequestMapSlice<T> = Record<string, AsyncRequestState<T>>; | ||||
| 
 | ||||
| function requestStateReducer<T, ThunkArg = void, ThunkApiConfig = {}>( | ||||
|   asyncThunk: AsyncThunk<T, ThunkArg, ThunkApiConfig>, | ||||
|   state: Draft<AsyncRequestState<T>> = initialAsyncRequestState, | ||||
|   action: AnyAction | ||||
| ): Draft<AsyncRequestState<T>> { | ||||
|   if (asyncThunk.pending.match(action)) { | ||||
|     return { | ||||
|       result: state.result, | ||||
|       loading: true, | ||||
|       error: state.error, | ||||
|       dispatched: true, | ||||
|       requestId: action.meta.requestId, | ||||
|     }; | ||||
|   } else if (asyncThunk.fulfilled.match(action)) { | ||||
|     if (state.requestId === action.meta.requestId) { | ||||
|       return { | ||||
|         ...state, | ||||
|         result: action.payload as Draft<T>, | ||||
|         loading: false, | ||||
|         error: undefined, | ||||
|       }; | ||||
|     } | ||||
|   } else if (asyncThunk.rejected.match(action)) { | ||||
|     if (state.requestId === action.meta.requestId) { | ||||
|       return { | ||||
|         ...state, | ||||
|         loading: false, | ||||
|         error: (action as any).error, | ||||
|       }; | ||||
|     } | ||||
|   } | ||||
|   return state; | ||||
| } | ||||
| 
 | ||||
| /* | ||||
|  * createAsyncSlice creates a slice based on a given async action, exposing it's state. | ||||
|  * takes care to only use state of the latest invocation of the action if there are several in flight. | ||||
|  */ | ||||
| export function createAsyncSlice<T, ThunkArg = void, ThunkApiConfig = {}>( | ||||
|   name: string, | ||||
|   asyncThunk: AsyncThunk<T, ThunkArg, ThunkApiConfig> | ||||
| ) { | ||||
|   return createSlice({ | ||||
|     name, | ||||
|     initialState: initialAsyncRequestState as AsyncRequestState<T>, | ||||
|     reducers: {}, | ||||
|     extraReducers: (builder) => | ||||
|       builder.addDefaultCase((state, action) => requestStateReducer(asyncThunk, state, action)), | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| /* | ||||
|  * createAsyncMapSlice creates a slice based on a given async action exposing a map of request states. | ||||
|  * separate requests are uniquely indentified by result of provided getEntityId function | ||||
|  * takes care to only use state of the latest invocation of the action if there are several in flight. | ||||
|  */ | ||||
| export function createAsyncMapSlice<T, ThunkArg = void, ThunkApiConfig = {}>( | ||||
|   name: string, | ||||
|   asyncThunk: AsyncThunk<T, ThunkArg, ThunkApiConfig>, | ||||
|   getEntityId: (arg: ThunkArg) => string | ||||
| ) { | ||||
|   return createSlice({ | ||||
|     name, | ||||
|     initialState: {} as AsyncRequestMapSlice<T>, | ||||
|     reducers: {}, | ||||
|     extraReducers: (builder) => | ||||
|       builder.addDefaultCase((state, action) => { | ||||
|         if (isAsyncThunkAction(asyncThunk)(action)) { | ||||
|           const entityId = getEntityId(action.meta.arg); | ||||
|           return { | ||||
|             ...state, | ||||
|             [entityId]: requestStateReducer(asyncThunk, state[entityId], action), | ||||
|           }; | ||||
|         } | ||||
|         return state; | ||||
|       }), | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| // rethrow promise error in redux serialized format
 | ||||
| export function withSerializedError<T>(p: Promise<T>): Promise<T> { | ||||
|   return p.catch((e) => { | ||||
|     const err: SerializedError = { | ||||
|       message: e.data?.message || e.message || e.statusText, | ||||
|       code: e.statusCode, | ||||
|     }; | ||||
|     throw err; | ||||
|   }); | ||||
| } | ||||
|  | @ -0,0 +1,38 @@ | |||
| import { | ||||
|   PromRuleType, | ||||
|   RulerAlertingRuleDTO, | ||||
|   RulerRecordingRuleDTO, | ||||
|   RulerRuleDTO, | ||||
| } from 'app/types/unified-alerting-dto'; | ||||
| import { Alert, AlertingRule, RecordingRule, Rule } from 'app/types/unified-alerting'; | ||||
| import { AsyncRequestState } from './redux'; | ||||
| import { RULER_NOT_SUPPORTED_MSG } from './constants'; | ||||
| import { hash } from './misc'; | ||||
| 
 | ||||
| export function isAlertingRule(rule: Rule): rule is AlertingRule { | ||||
|   return rule.type === PromRuleType.Alerting; | ||||
| } | ||||
| 
 | ||||
| export function isRecordingRule(rule: Rule): rule is RecordingRule { | ||||
|   return rule.type === PromRuleType.Recording; | ||||
| } | ||||
| 
 | ||||
| export function isAlertingRulerRule(rule: RulerRuleDTO): rule is RulerAlertingRuleDTO { | ||||
|   return 'alert' in rule; | ||||
| } | ||||
| 
 | ||||
| export function isRecordingRulerRule(rule: RulerRuleDTO): rule is RulerRecordingRuleDTO { | ||||
|   return 'record' in rule; | ||||
| } | ||||
| 
 | ||||
| export function alertInstanceKey(alert: Alert): string { | ||||
|   return JSON.stringify(alert.labels); | ||||
| } | ||||
| 
 | ||||
| export function isRulerNotSupportedResponse(resp: AsyncRequestState<any>) { | ||||
|   return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG; | ||||
| } | ||||
| 
 | ||||
| export function hashRulerRule(rule: RulerRuleDTO): number { | ||||
|   return hash(JSON.stringify(rule)); | ||||
| } | ||||
|  | @ -37,6 +37,8 @@ const azureMonitorPlugin = async () => | |||
|   ); | ||||
| const tempoPlugin = async () => | ||||
|   await import(/* webpackChunkName: "tempoPlugin" */ 'app/plugins/datasource/tempo/module'); | ||||
| const alertmanagerPlugin = async () => | ||||
|   await import(/* webpackChunkName: "alertmanagerPlugin" */ 'app/plugins/datasource/alertmanager/module'); | ||||
| 
 | ||||
| import * as textPanel from 'app/plugins/panel/text/module'; | ||||
| import * as timeseriesPanel from 'app/plugins/panel/timeseries/module'; | ||||
|  | @ -84,6 +86,7 @@ const builtInPlugins: any = { | |||
|   'app/plugins/datasource/cloud-monitoring/module': cloudMonitoringPlugin, | ||||
|   'app/plugins/datasource/grafana-azure-monitor-datasource/module': azureMonitorPlugin, | ||||
|   'app/plugins/datasource/tempo/module': tempoPlugin, | ||||
|   'app/plugins/datasource/alertmanager/module': alertmanagerPlugin, | ||||
| 
 | ||||
|   'app/plugins/panel/text/module': textPanel, | ||||
|   'app/plugins/panel/timeseries/module': timeseriesPanel, | ||||
|  |  | |||
|  | @ -0,0 +1,18 @@ | |||
| import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; | ||||
| import { DataSourceHttpSettings } from '@grafana/ui'; | ||||
| import React from 'react'; | ||||
| 
 | ||||
| export type Props = DataSourcePluginOptionsEditorProps; | ||||
| 
 | ||||
| export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => { | ||||
|   return ( | ||||
|     <> | ||||
|       <DataSourceHttpSettings | ||||
|         defaultUrl={''} | ||||
|         dataSourceConfig={options} | ||||
|         showAccessOptions={true} | ||||
|         onChange={onOptionsChange} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1,62 @@ | |||
| import { DataQuery, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; | ||||
| import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; | ||||
| import { Observable, of } from 'rxjs'; | ||||
| 
 | ||||
| export type AlertManagerQuery = { | ||||
|   query: string; | ||||
| } & DataQuery; | ||||
| 
 | ||||
| export class AlertManagerDatasource extends DataSourceApi<AlertManagerQuery> { | ||||
|   constructor(public instanceSettings: DataSourceInstanceSettings) { | ||||
|     super(instanceSettings); | ||||
|   } | ||||
| 
 | ||||
|   // `query()` has to be implemented but we actually don't use it, just need this
 | ||||
|   // data source to proxy requests.
 | ||||
|   // @ts-ignore
 | ||||
|   query(): Observable<DataQueryResponse> { | ||||
|     return of({ | ||||
|       data: [], | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   _request(url: string) { | ||||
|     const options: BackendSrvRequest = { | ||||
|       headers: {}, | ||||
|       method: 'GET', | ||||
|       url: this.instanceSettings.url + url, | ||||
|     }; | ||||
| 
 | ||||
|     if (this.instanceSettings.basicAuth || this.instanceSettings.withCredentials) { | ||||
|       this.instanceSettings.withCredentials = true; | ||||
|     } | ||||
| 
 | ||||
|     if (this.instanceSettings.basicAuth) { | ||||
|       options.headers!.Authorization = this.instanceSettings.basicAuth; | ||||
|     } | ||||
| 
 | ||||
|     return getBackendSrv().fetch<any>(options).toPromise(); | ||||
|   } | ||||
| 
 | ||||
|   async testDatasource() { | ||||
|     let alertmanagerResponse; | ||||
|     let cortexAlertmanagerResponse; | ||||
| 
 | ||||
|     try { | ||||
|       alertmanagerResponse = await this._request('/api/v2/status'); | ||||
|     } catch (e) {} | ||||
|     try { | ||||
|       cortexAlertmanagerResponse = await this._request('/alertmanager/api/v2/status'); | ||||
|     } catch (e) {} | ||||
| 
 | ||||
|     return alertmanagerResponse?.status === 200 || cortexAlertmanagerResponse?.status === 200 | ||||
|       ? { | ||||
|           status: 'success', | ||||
|           message: 'Health check passed.', | ||||
|         } | ||||
|       : { | ||||
|           status: 'error', | ||||
|           message: 'Health check failed.', | ||||
|         }; | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1 @@ | |||
| <svg width="46" height="48" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M0 20.425C0 9.146 9.154 0 20.445 0c11.29 0 20.444 9.144 20.444 20.425 0 1.756-.222 3.46-.639 5.086a9.033 9.033 0 0 0-.312-.124 3.149 3.149 0 0 0-5.365-1.955 3.149 3.149 0 0 0-.911 1.955 8.9 8.9 0 0 0-3.982 2.986H10.836v3.482h17.26A8.882 8.882 0 0 0 27.9 33.7v2.46a5.449 5.449 0 0 0-2.284 4.032c-1.652.43-3.385.66-5.171.66C9.154 40.851 0 31.705 0 20.425zM14.628 33.44c0 2.645 2.604 4.788 5.817 4.788 3.212 0 5.817-2.144 5.817-4.788H14.628zm-3.737-6.858h19.092c2.448-2.521 3.086-5.139 3.085-5.138l-4.384.854s-.932.229-2.282.482a7.897 7.897 0 0 0 1.89-5.1c0-2.841-.692-4.343-1.835-6.326-.708-1.228-1.743-3.784-.841-5.827-2.045 1.622-2.599 6.32-2.718 9.54-.16-.695-.237-1.57-.314-2.454-.096-1.089-.192-2.192-.448-2.993a40.253 40.253 0 0 0-1.063-2.877c-.775-1.93-1.239-3.084-.46-4.368-1.238.074-2.477 2.256-2.477 4.134 0 1.814-.49 4.772-1.664 6.392-.08-3.498-1.26-5.736-2.364-5.825.62 1.581-.039 3.368-.764 5.332-.6 1.628-1.245 3.377-1.245 5.232 0 1.865.728 3.69 1.953 5.125-1.276-.233-2.15-.435-2.15-.435-1.696-.381-4.089-.911-4.081-.87.45 1.27.913 2.516 2.88 4.902a7.116 7.116 0 0 0 .19.22z" fill="#DA4E31"/><path d="M43.7 37.356v-3.654a6.89 6.89 0 0 0-1.632-4.445 6.901 6.901 0 0 0-4.118-2.345V25.66a1.149 1.149 0 0 0-1.963-.813 1.149 1.149 0 0 0-.337.813v1.252a6.901 6.901 0 0 0-4.118 2.345 6.89 6.89 0 0 0-1.632 4.445v3.654a3.45 3.45 0 0 0-2.3 3.24v2.298a1.149 1.149 0 0 0 1.15 1.149h3.611a4.596 4.596 0 0 0 4.439 3.39 4.602 4.602 0 0 0 4.439-3.39h3.611a1.15 1.15 0 0 0 1.15-1.15v-2.297a3.444 3.444 0 0 0-2.3-3.24zm-11.5-3.654a4.594 4.594 0 0 1 4.6-4.596 4.594 4.594 0 0 1 4.6 4.596v3.447h-9.2v-3.447zm4.6 11.49a2.302 2.302 0 0 1-1.978-1.15h3.956a2.3 2.3 0 0 1-1.978 1.15zm6.9-3.447H29.9v-1.15a1.149 1.149 0 0 1 1.15-1.148h11.5a1.15 1.15 0 0 1 1.15 1.149v1.149z" fill="#747474" fill-rule="nonzero"/></g></svg> | ||||
| After Width: | Height: | Size: 1.9 KiB | 
|  | @ -0,0 +1,8 @@ | |||
| import { DataSourcePlugin } from '@grafana/data'; | ||||
| import { ConfigEditor } from './ConfigEditor'; | ||||
| import { AlertManagerDatasource } from './DataSource'; | ||||
| 
 | ||||
| // This is not actually a data source but since 7.1,
 | ||||
| // it is required to specify query types. Which we don't have.
 | ||||
| // @ts-ignore
 | ||||
| export const plugin = new DataSourcePlugin(AlertManagerDatasource).setConfigEditor(ConfigEditor); | ||||
|  | @ -0,0 +1,63 @@ | |||
| { | ||||
|   "type": "datasource", | ||||
|   "name": "Alert Manager", | ||||
|   "id": "alertmanager", | ||||
|   "metrics": false, | ||||
|   "state": "alpha", | ||||
|   "routes": [ | ||||
|     { | ||||
|       "method": "POST", | ||||
|       "path": "alertmanager/api/v2/silences", | ||||
|       "reqRole": "Editor" | ||||
|     }, | ||||
|     { | ||||
|       "method": "DELETE", | ||||
|       "path": "alertmanager/api/v2/silence", | ||||
|       "reqRole": "Editor" | ||||
|     }, | ||||
|     { | ||||
|       "method": "GET", | ||||
|       "path": "alertmanager/api/v2/silences", | ||||
|       "reqRole": "Viewer" | ||||
|     }, | ||||
|     { | ||||
|       "method": "POST", | ||||
|       "reqRole": "Admin" | ||||
|     }, | ||||
|     { | ||||
|       "method": "PUT", | ||||
|       "reqRole": "Admin" | ||||
|     }, | ||||
|     { | ||||
|       "method": "DELETE", | ||||
|       "reqRole": "Admin" | ||||
|     }, | ||||
|     { | ||||
|       "method": "GET", | ||||
|       "path": "alertmanager/api/v2/alerts", | ||||
|       "reqRole": "Viewer" | ||||
|     }, | ||||
|     { | ||||
|       "method": "GET", | ||||
|       "path": "api/v1/alerts", | ||||
|       "reqRole": "Admin" | ||||
|     } | ||||
|   ], | ||||
|   "info": { | ||||
|     "description": "", | ||||
|     "author": { | ||||
|       "name": "Prometheus alertmanager", | ||||
|       "url": "https://grafana.com" | ||||
|     }, | ||||
|     "logos": { | ||||
|       "small": "img/logo.svg", | ||||
|       "large": "img/logo.svg" | ||||
|     }, | ||||
|     "links": [ | ||||
|       { | ||||
|         "name": "Learn more", | ||||
|         "url": "https://prometheus.io/docs/alerting/latest/alertmanager/" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,143 @@ | |||
| //DOCS: https://prometheus.io/docs/alerting/latest/configuration/
 | ||||
| 
 | ||||
| export type AlertManagerCortexConfig = { | ||||
|   template_files: Record<string, string>; | ||||
|   alertmanager_config: AlertmanagerConfig; | ||||
| }; | ||||
| 
 | ||||
| // NOTE - This type is incomplete! But currently, we don't need more.
 | ||||
| export type AlertmanagerStatusPayload = { | ||||
|   config: { | ||||
|     original: string; | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export type TLSConfig = { | ||||
|   ca_file: string; | ||||
|   cert_file: string; | ||||
|   key_file: string; | ||||
|   server_name?: string; | ||||
|   insecure_skip_verify?: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type HTTPConfigCommon = { | ||||
|   proxy_url?: string; | ||||
|   tls_config?: TLSConfig; | ||||
| }; | ||||
| 
 | ||||
| export type HTTPConfigBasicAuth = { | ||||
|   basic_auth: { | ||||
|     username: string; | ||||
|   } & ({ password: string } | { password_file: string }); | ||||
| }; | ||||
| 
 | ||||
| export type HTTPConfigBearerToken = { | ||||
|   bearer_token: string; | ||||
| }; | ||||
| 
 | ||||
| export type HTTPConfigBearerTokenFile = { | ||||
|   bearer_token_file: string; | ||||
| }; | ||||
| 
 | ||||
| export type HTTPConfig = HTTPConfigCommon & (HTTPConfigBasicAuth | HTTPConfigBearerToken | HTTPConfigBearerTokenFile); | ||||
| 
 | ||||
| export type EmailConfig = { | ||||
|   to: string; | ||||
| 
 | ||||
|   send_resolved?: string; | ||||
|   from?: string; | ||||
|   smarthost?: string; | ||||
|   hello?: string; | ||||
|   auth_username?: string; | ||||
|   auth_password?: string; | ||||
|   auth_secret?: string; | ||||
|   auth_identity?: string; | ||||
|   require_tls?: boolean; | ||||
|   tls_config?: TLSConfig; | ||||
|   html?: string; | ||||
|   text?: string; | ||||
|   headers?: Record<string, string>; | ||||
| }; | ||||
| 
 | ||||
| export type WebhookConfig = { | ||||
|   url: string; | ||||
| 
 | ||||
|   send_resolved?: boolean; | ||||
|   http_config?: HTTPConfig; | ||||
|   max_alerts?: number; | ||||
| }; | ||||
| 
 | ||||
| export type GrafanaManagedReceiverConfig = { | ||||
|   id?: number; | ||||
|   frequency: number; | ||||
|   disableResolveMessage: boolean; | ||||
|   secureFields: Record<string, unknown>; | ||||
|   settings: Record<string, unknown>; | ||||
|   sendReminder: boolean; | ||||
|   type: string; | ||||
|   uid: string; | ||||
|   updated?: string; | ||||
|   created?: string; | ||||
| }; | ||||
| 
 | ||||
| export type Receiver = { | ||||
|   name: string; | ||||
| 
 | ||||
|   email_configs?: EmailConfig[]; | ||||
|   pagerduty_configs?: unknown[]; | ||||
|   pushover_configs?: unknown[]; | ||||
|   slack_configs?: unknown[]; | ||||
|   opsgenie_configs?: unknown[]; | ||||
|   webhook_configs?: WebhookConfig[]; | ||||
|   victorops_configs?: unknown[]; | ||||
|   wechat_configs?: unknown[]; | ||||
|   grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; | ||||
| }; | ||||
| 
 | ||||
| export type Route = { | ||||
|   receiver?: string; | ||||
|   group_by?: string[]; | ||||
|   continue?: boolean; | ||||
|   match?: Record<string, string>; | ||||
|   match_re?: Record<string, string>; | ||||
|   group_wait?: string; | ||||
|   group_interval?: string; | ||||
|   repeat_itnerval?: string; | ||||
|   routes?: Route[]; | ||||
| }; | ||||
| 
 | ||||
| export type InhibitRule = { | ||||
|   target_match: Record<string, string>; | ||||
|   target_match_re: Record<string, string>; | ||||
|   source_match: Record<string, string>; | ||||
|   source_match_re: Record<string, string>; | ||||
|   equal?: string[]; | ||||
| }; | ||||
| 
 | ||||
| export type AlertmanagerConfig = { | ||||
|   global?: { | ||||
|     smtp_from?: string; | ||||
|     smtp_smarthost?: string; | ||||
|     smtp_hello?: string; | ||||
|     smtp_auth_username?: string; | ||||
|     smtp_auth_password?: string; | ||||
|     smtp_auth_identity?: string; | ||||
|     smtp_auth_secret?: string; | ||||
|     smtp_require_tls?: boolean; | ||||
|     slack_api_url?: string; | ||||
|     victorops_api_key?: string; | ||||
|     victorops_api_url?: string; | ||||
|     pagerduty_url?: string; | ||||
|     opsgenie_api_key?: string; | ||||
|     opsgenie_api_url?: string; | ||||
|     wechat_api_url?: string; | ||||
|     wechat_api_secret?: string; | ||||
|     wechat_api_corp_id?: string; | ||||
|     http_config?: HTTPConfig; | ||||
|     resolve_timeout?: string; | ||||
|   }; | ||||
|   templates?: string[]; | ||||
|   route?: Route; | ||||
|   inhibit_rules?: InhibitRule[]; | ||||
|   receivers?: Receiver[]; | ||||
| }; | ||||
|  | @ -347,7 +347,14 @@ export function getAppRoutes(): RouteDescriptor[] { | |||
|     { | ||||
|       path: '/alerting/list', | ||||
|       component: SafeDynamicImport( | ||||
|         () => import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList') | ||||
|         () => import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleListIndex') | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       path: '/alerting/routes', | ||||
|       roles: () => ['Admin'], | ||||
|       component: SafeDynamicImport( | ||||
|         () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes') | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|  | @ -374,14 +381,20 @@ export function getAppRoutes(): RouteDescriptor[] { | |||
|       path: '/alerting/new', | ||||
|       pageClass: 'page-alerting', | ||||
|       component: SafeDynamicImport( | ||||
|         () => import(/* webpackChunkName: "NgAlertingPage"*/ 'app/features/alerting/NextGenAlertingPage') | ||||
|         () => | ||||
|           import( | ||||
|             /* webpackChunkName: "NgAlertingPage"*/ 'app/features/alerting/unified/components/rule-editor/AlertRuleForm' | ||||
|           ) | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       path: '/alerting/:id/edit', | ||||
|       pageClass: 'page-alerting', | ||||
|       component: SafeDynamicImport( | ||||
|         () => import(/* webpackChunkName: "NgAlertingPage"*/ 'app/features/alerting/NextGenAlertingPage') | ||||
|         () => | ||||
|           import( | ||||
|             /* webpackChunkName: "NgAlertingPage"*/ 'app/features/alerting/unified/components/rule-editor/AlertRuleForm' | ||||
|           ) | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk' | |||
| import { PayloadAction } from '@reduxjs/toolkit'; | ||||
| import { NavIndex } from '@grafana/data'; | ||||
| import { AlertDefinitionState, AlertRulesState, NotificationChannelState } from './alerting'; | ||||
| import { UnifiedAlertingState } from '../features/alerting/unified/state/reducers'; | ||||
| import { TeamsState, TeamState } from './teams'; | ||||
| import { FolderState } from './folders'; | ||||
| import { DashboardState } from './dashboard'; | ||||
|  | @ -43,6 +44,7 @@ export interface StoreState { | |||
|   importDashboard: ImportDashboardState; | ||||
|   notificationChannel: NotificationChannelState; | ||||
|   alertDefinition: AlertDefinitionState; | ||||
|   unifiedAlerting: UnifiedAlertingState; | ||||
| } | ||||
| 
 | ||||
| /* | ||||
|  |  | |||
|  | @ -0,0 +1,96 @@ | |||
| // Prometheus API DTOs, possibly to be autogenerated from openapi spec in the near future
 | ||||
| 
 | ||||
| export type Labels = Record<string, string>; | ||||
| export type Annotations = Record<string, string>; | ||||
| 
 | ||||
| export enum PromAlertingRuleState { | ||||
|   Firing = 'firing', | ||||
|   Inactive = 'inactive', | ||||
|   Pending = 'pending', | ||||
| } | ||||
| 
 | ||||
| export enum PromRuleType { | ||||
|   Alerting = 'alerting', | ||||
|   Recording = 'recording', | ||||
| } | ||||
| 
 | ||||
| interface PromRuleDTOBase { | ||||
|   health: string; | ||||
|   name: string; | ||||
|   query: string; // expr
 | ||||
|   evaluationTime?: number; | ||||
|   lastEvaluation?: string; | ||||
|   lastError?: string; | ||||
| } | ||||
| 
 | ||||
| export interface PromAlertingRuleDTO extends PromRuleDTOBase { | ||||
|   alerts: Array<{ | ||||
|     labels: Labels; | ||||
|     annotations: Annotations; | ||||
|     state: Exclude<PromAlertingRuleState, PromAlertingRuleState.Inactive>; | ||||
|     activeAt: string; | ||||
|     value: string; | ||||
|   }>; | ||||
|   labels: Labels; | ||||
|   annotations: Annotations; | ||||
|   duration?: number; // for
 | ||||
|   state: PromAlertingRuleState; | ||||
|   type: PromRuleType.Alerting; | ||||
| } | ||||
| 
 | ||||
| export interface PromRecordingRuleDTO extends PromRuleDTOBase { | ||||
|   health: string; | ||||
|   name: string; | ||||
|   query: string; // expr
 | ||||
|   type: PromRuleType.Recording; | ||||
|   labels?: Labels; | ||||
| } | ||||
| 
 | ||||
| export type PromRuleDTO = PromAlertingRuleDTO | PromRecordingRuleDTO; | ||||
| 
 | ||||
| export interface PromRuleGroupDTO { | ||||
|   name: string; | ||||
|   file: string; | ||||
|   rules: PromRuleDTO[]; | ||||
|   interval: number; | ||||
| 
 | ||||
|   evaluationTime?: number; // these 2 are not in older prometheus payloads
 | ||||
|   lastEvaluation?: string; | ||||
| } | ||||
| 
 | ||||
| export interface PromResponse<T> { | ||||
|   status: 'success' | 'error' | ''; // mocks return empty string
 | ||||
|   data: T; | ||||
|   errorType?: string; | ||||
|   error?: string; | ||||
|   warnings?: string[]; | ||||
| } | ||||
| 
 | ||||
| export type PromRulesResponse = PromResponse<{ groups: PromRuleGroupDTO[] }>; | ||||
| 
 | ||||
| // Ruler rule DTOs
 | ||||
| 
 | ||||
| interface RulerRuleBaseDTO { | ||||
|   expr: string; | ||||
|   labels?: Labels; | ||||
| } | ||||
| 
 | ||||
| export interface RulerRecordingRuleDTO extends RulerRuleBaseDTO { | ||||
|   record: string; | ||||
| } | ||||
| 
 | ||||
| export interface RulerAlertingRuleDTO extends RulerRuleBaseDTO { | ||||
|   alert: string; | ||||
|   for?: string; | ||||
|   annotations?: Annotations; | ||||
| } | ||||
| 
 | ||||
| export type RulerRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO; | ||||
| 
 | ||||
| export type RulerRuleGroupDTO = { | ||||
|   name: string; | ||||
|   interval?: string; | ||||
|   rules: RulerRuleDTO[]; | ||||
| }; | ||||
| 
 | ||||
| export type RulerRulesConfigDTO = { [namespace: string]: RulerRuleGroupDTO[] }; | ||||
|  | @ -0,0 +1,93 @@ | |||
| /* Prometheus internal models */ | ||||
| 
 | ||||
| import { DataSourceInstanceSettings } from '@grafana/data'; | ||||
| import { PromAlertingRuleState, PromRuleType, RulerRuleDTO, Labels, Annotations } from './unified-alerting-dto'; | ||||
| 
 | ||||
| export type Alert = { | ||||
|   activeAt: string; | ||||
|   annotations: { [key: string]: string }; | ||||
|   labels: { [key: string]: string }; | ||||
|   state: PromAlertingRuleState; | ||||
|   value: string; | ||||
| }; | ||||
| 
 | ||||
| interface RuleBase { | ||||
|   health: string; | ||||
|   name: string; | ||||
|   query: string; | ||||
|   lastEvaluation?: string; | ||||
|   evaluationTime?: number; | ||||
|   lastError?: string; | ||||
| } | ||||
| 
 | ||||
| export interface AlertingRule extends RuleBase { | ||||
|   alerts: Alert[]; | ||||
|   labels: { | ||||
|     [key: string]: string; | ||||
|   }; | ||||
|   annotations?: { | ||||
|     [key: string]: string; | ||||
|   }; | ||||
|   state: PromAlertingRuleState; | ||||
|   type: PromRuleType.Alerting; | ||||
| } | ||||
| 
 | ||||
| export interface RecordingRule extends RuleBase { | ||||
|   type: PromRuleType.Recording; | ||||
| 
 | ||||
|   labels?: { | ||||
|     [key: string]: string; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export type Rule = AlertingRule | RecordingRule; | ||||
| 
 | ||||
| export type BaseRuleGroup = { name: string }; | ||||
| 
 | ||||
| export interface RuleGroup { | ||||
|   name: string; | ||||
|   interval: number; | ||||
|   rules: Rule[]; | ||||
| } | ||||
| 
 | ||||
| export interface RuleNamespace { | ||||
|   dataSourceName: string; | ||||
|   name: string; | ||||
|   groups: RuleGroup[]; | ||||
| } | ||||
| 
 | ||||
| export interface RulesSourceResult { | ||||
|   dataSourceName: string; | ||||
|   error?: unknown; | ||||
|   namespaces?: RuleNamespace[]; | ||||
| } | ||||
| 
 | ||||
| export type RulesSource = DataSourceInstanceSettings | 'grafana'; | ||||
| 
 | ||||
| // combined prom and ruler result
 | ||||
| export interface CombinedRule { | ||||
|   name: string; | ||||
|   query: string; | ||||
|   labels: Labels; | ||||
|   annotations: Annotations; | ||||
|   promRule?: Rule; | ||||
|   rulerRule?: RulerRuleDTO; | ||||
| } | ||||
| 
 | ||||
| export interface CombinedRuleGroup { | ||||
|   name: string; | ||||
|   rules: CombinedRule[]; | ||||
| } | ||||
| 
 | ||||
| export interface CombinedRuleNamespace { | ||||
|   rulesSource: RulesSource; | ||||
|   name: string; | ||||
|   groups: CombinedRuleGroup[]; | ||||
| } | ||||
| 
 | ||||
| export interface RuleLocation { | ||||
|   ruleSourceName: string; | ||||
|   namespace: string; | ||||
|   groupName: string; | ||||
|   ruleHash: number; | ||||
| } | ||||
|  | @ -0,0 +1,9 @@ | |||
| /* type a mocked function as jest mock, example: | ||||
|  * import { doFoo } from 'foo'; | ||||
|  * | ||||
|  * jest.mock('foo'); | ||||
|  * | ||||
|  * const doFooMock = typeAsJestMock(doFoo); // doFooMock is of type jest.Mock with proper return type for doFoo
 | ||||
|  */ | ||||
| 
 | ||||
| export const typeAsJestMock = <T extends (...args: any) => any>(fn: T) => (fn as unknown) as jest.Mock<ReturnType<T>>; | ||||
|  | @ -0,0 +1 @@ | |||
| docker run --rm --name promtail --volume "/etc/promtail:/etc/promtail" --volume "/var/log:/var/log" grafana/promtail:master -config.file=/etc/promtail/config.yaml | ||||
							
								
								
									
										15
									
								
								yarn.lock
								
								
								
								
							
							
						
						
									
										15
									
								
								yarn.lock
								
								
								
								
							|  | @ -5825,6 +5825,11 @@ | |||
|   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" | ||||
|   integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== | ||||
| 
 | ||||
| "@types/pluralize@^0.0.29": | ||||
|   version "0.0.29" | ||||
|   resolved "https://registry.yarnpkg.com/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c" | ||||
|   integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA== | ||||
| 
 | ||||
| "@types/prettier@^1.16.4": | ||||
|   version "1.18.3" | ||||
|   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-1.18.3.tgz#64ff53329ce16139f17c3db9d3e0487199972cd8" | ||||
|  | @ -19147,6 +19152,11 @@ pluralize@^1.2.1: | |||
|   resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" | ||||
|   integrity sha1-0aIUg/0iu0HlihL6NCGCMUCJfEU= | ||||
| 
 | ||||
| pluralize@^8.0.0: | ||||
|   version "8.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" | ||||
|   integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== | ||||
| 
 | ||||
| pn@^1.1.0: | ||||
|   version "1.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" | ||||
|  | @ -24174,6 +24184,11 @@ test-exclude@^6.0.0: | |||
|     glob "^7.1.4" | ||||
|     minimatch "^3.0.4" | ||||
| 
 | ||||
| testing-library-selector@^0.1.3: | ||||
|   version "0.1.3" | ||||
|   resolved "https://registry.yarnpkg.com/testing-library-selector/-/testing-library-selector-0.1.3.tgz#c752ca78a7c0b348e6b3ebcaf14e5b337493621f" | ||||
|   integrity sha512-mCrZR5dv3IGUlUUWsYs5XQgCZQ5tui8q9t4GAwDlKXAXZWFAYcyCurD+Xet06bjY43JagBLdzvDIlGeNgigd/w== | ||||
| 
 | ||||
| "tether-drop@https://github.com/torkelo/drop": | ||||
|   version "1.5.0" | ||||
|   resolved "https://github.com/torkelo/drop#fc83ca88db0076fbf6359cbe1743a9ef0f1ee6e1" | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue