Alerting: unified alerting frontend (#32708)

This commit is contained in:
Domas 2021-04-07 08:42:43 +03:00 committed by GitHub
parent 6082a9360e
commit a56293142a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 3857 additions and 28 deletions

1
.github/CODEOWNERS vendored
View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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",

View File

@ -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]);

View File

@ -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{

View File

@ -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{}),
)
}

View File

@ -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

View File

@ -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())

View File

@ -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>

View File

@ -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>;
};

View File

@ -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;

View 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];
}

View 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;

View File

@ -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(

View 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;

View 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();
});
});

View 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;
`,
});

View 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
);
}

View 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);
}

View 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;
}
}

View File

@ -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;
`;

View File

@ -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};
`,
});

View File

@ -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}
/>
);
};

View File

@ -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>
);
};

View File

@ -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};
`,
});

View File

@ -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;
}
`,
});

View File

@ -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};
`,
});

View File

@ -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};
`,
});

View 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};
`,
});

View File

@ -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>;
};

View 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};
`,
});

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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};
`;

View File

@ -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;
`;

View File

@ -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>
);
};

View File

@ -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};
}
`,
});

View File

@ -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};
}
`,
});

View File

@ -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"
/>
);

View File

@ -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};
`,
});

View File

@ -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};
}
`,
});

View File

@ -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};
}
`,
});

View File

@ -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};
`,
});

View File

@ -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};
`,
});

View File

@ -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];
}

View File

@ -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('');
}

View 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;
}

View File

@ -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);
}

View 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,
};
};

View 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));
};
}

View 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;

View 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};
`,
});

View 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);
}

View File

@ -0,0 +1,3 @@
export const RULER_NOT_SUPPORTED_MSG = 'ruler not supported';
export const RULE_LIST_POLL_INTERVAL_MS = 20000;

View 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);
}

View 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;
}

View 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;
});
}

View 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));
}

View File

@ -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,

View 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}
/>
</>
);
};

View 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.',
};
}
}

View 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

View 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);

View 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/"
}
]
}
}

View 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[];
};

View File

@ -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'
)
),
},
{

View File

@ -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;
}
/*

View 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[] };

View 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;
}

View 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
View 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

View File

@ -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"