mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: unified alerting frontend (#32708)
This commit is contained in:
parent
6082a9360e
commit
a56293142a
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -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;
|
||||
|
11
public/app/core/hooks/useQueryParams.ts
Normal file
11
public/app/core/hooks/useQueryParams.ts
Normal file
@ -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];
|
||||
}
|
7
public/app/features/alerting/AlertRuleListIndex.tsx
Normal file
7
public/app/features/alerting/AlertRuleListIndex.tsx
Normal file
@ -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(
|
||||
|
39
public/app/features/alerting/unified/AmRoutes.tsx
Normal file
39
public/app/features/alerting/unified/AmRoutes.tsx
Normal file
@ -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;
|
284
public/app/features/alerting/unified/RuleList.test.tsx
Normal file
284
public/app/features/alerting/unified/RuleList.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
142
public/app/features/alerting/unified/RuleList.tsx
Normal file
142
public/app/features/alerting/unified/RuleList.tsx
Normal file
@ -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;
|
||||
`,
|
||||
});
|
39
public/app/features/alerting/unified/api/alertmanager.ts
Normal file
39
public/app/features/alerting/unified/api/alertmanager.ts
Normal file
@ -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
|
||||
);
|
||||
}
|
29
public/app/features/alerting/unified/api/prometheus.ts
Normal file
29
public/app/features/alerting/unified/api/prometheus.ts
Normal file
@ -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);
|
||||
}
|
81
public/app/features/alerting/unified/api/ruler.ts
Normal file
81
public/app/features/alerting/unified/api/ruler.ts
Normal file
@ -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};
|
||||
`,
|
||||
});
|
39
public/app/features/alerting/unified/components/StateTag.tsx
Normal file
39
public/app/features/alerting/unified/components/StateTag.tsx
Normal file
@ -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>;
|
||||
};
|
20
public/app/features/alerting/unified/components/Well.tsx
Normal file
20
public/app/features/alerting/unified/components/Well.tsx
Normal file
@ -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('');
|
||||
}
|
10
public/app/features/alerting/unified/hooks/useHasRuler.ts
Normal file
10
public/app/features/alerting/unified/hooks/useHasRuler.ts
Normal file
@ -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);
|
||||
}
|
93
public/app/features/alerting/unified/mocks.ts
Normal file
93
public/app/features/alerting/unified/mocks.ts
Normal file
@ -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,
|
||||
};
|
||||
};
|
73
public/app/features/alerting/unified/state/actions.ts
Normal file
73
public/app/features/alerting/unified/state/actions.ts
Normal file
@ -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));
|
||||
};
|
||||
}
|
17
public/app/features/alerting/unified/state/reducers.ts
Normal file
17
public/app/features/alerting/unified/state/reducers.ts
Normal file
@ -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;
|
26
public/app/features/alerting/unified/styles/table.ts
Normal file
26
public/app/features/alerting/unified/styles/table.ts
Normal file
@ -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};
|
||||
`,
|
||||
});
|
6
public/app/features/alerting/unified/utils/config.ts
Normal file
6
public/app/features/alerting/unified/utils/config.ts
Normal file
@ -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);
|
||||
}
|
3
public/app/features/alerting/unified/utils/constants.ts
Normal file
3
public/app/features/alerting/unified/utils/constants.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const RULER_NOT_SUPPORTED_MSG = 'ruler not supported';
|
||||
|
||||
export const RULE_LIST_POLL_INTERVAL_MS = 20000;
|
67
public/app/features/alerting/unified/utils/datasource.ts
Normal file
67
public/app/features/alerting/unified/utils/datasource.ts
Normal file
@ -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);
|
||||
}
|
28
public/app/features/alerting/unified/utils/misc.ts
Normal file
28
public/app/features/alerting/unified/utils/misc.ts
Normal file
@ -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;
|
||||
}
|
106
public/app/features/alerting/unified/utils/redux.ts
Normal file
106
public/app/features/alerting/unified/utils/redux.ts
Normal file
@ -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;
|
||||
});
|
||||
}
|
38
public/app/features/alerting/unified/utils/rules.ts
Normal file
38
public/app/features/alerting/unified/utils/rules.ts
Normal file
@ -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,
|
||||
|
18
public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
Normal file
18
public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
Normal file
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
62
public/app/plugins/datasource/alertmanager/DataSource.ts
Normal file
62
public/app/plugins/datasource/alertmanager/DataSource.ts
Normal file
@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
1
public/app/plugins/datasource/alertmanager/img/logo.svg
Normal file
1
public/app/plugins/datasource/alertmanager/img/logo.svg
Normal file
@ -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 |
8
public/app/plugins/datasource/alertmanager/module.ts
Normal file
8
public/app/plugins/datasource/alertmanager/module.ts
Normal file
@ -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);
|
63
public/app/plugins/datasource/alertmanager/plugin.json
Normal file
63
public/app/plugins/datasource/alertmanager/plugin.json
Normal file
@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
143
public/app/plugins/datasource/alertmanager/types.ts
Normal file
143
public/app/plugins/datasource/alertmanager/types.ts
Normal file
@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
96
public/app/types/unified-alerting-dto.ts
Normal file
96
public/app/types/unified-alerting-dto.ts
Normal file
@ -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[] };
|
93
public/app/types/unified-alerting.ts
Normal file
93
public/app/types/unified-alerting.ts
Normal file
@ -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;
|
||||
}
|
9
public/test/helpers/typeAsJestMock.ts
Normal file
9
public/test/helpers/typeAsJestMock.ts
Normal file
@ -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>>;
|
1
start-promtail.sh
Executable file
1
start-promtail.sh
Executable file
@ -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
Block a user