diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index bb0f40ea205..debee02c499 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -16,6 +16,8 @@ on: required: true metricsWriteAPIKey: required: true +env: + YARN_ENABLE_IMMUTABLE_INSTALLS: false jobs: main: runs-on: ubuntu-latest @@ -80,7 +82,7 @@ jobs: ref: main - uses: actions/setup-node@v2.5.1 with: - node-version: '14' + node-version: '16' - name: Install Actions run: npm install --production --prefix ./actions - name: Run bump version (manually invoked) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f4e77db4c..2a85334d4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ + + +# 8.4.2 (2022-02-23) + +### Features and enhancements + +- **OAuth:** Add setting to skip org assignment for external users. [#34834](https://github.com/grafana/grafana/pull/34834), [@baez90](https://github.com/baez90) +- **Tracing:** Add option to map tag names to log label names in trace to logs settings. [#45178](https://github.com/grafana/grafana/pull/45178), [@connorlindsey](https://github.com/connorlindsey) + +### Bug fixes + +- **Explore:** Fix closing split pane when logs panel is used. [#45602](https://github.com/grafana/grafana/pull/45602), [@ifrost](https://github.com/ifrost) + + # 8.4.1 (2022-02-18) diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 9287a8a056c..a5f6fab22eb 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -14,7 +14,7 @@ Grafana has default and custom configuration files. You can customize your Grafa ## Configuration file location -The default settings for a Grafana instance are stored in the `$WORKING_DIR/conf/defaults.ini` file. _Do not_ change the location in this file. +The default settings for a Grafana instance are stored in the `$WORKING_DIR/conf/defaults.ini` file. _Do not_ change this file. Depending on your OS, your custom configuration file is either the `$WORKING_DIR/conf/defaults.ini` file or the `/usr/local/etc/grafana/grafana.ini` file. The custom configuration file path can be overridden using the `--config` parameter. diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md b/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md index c51c957447c..e7b634905c5 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md @@ -20,7 +20,7 @@ Labels are key-value pairs that contain information about, and are used to uniqu ### How are labels used? - The complete set of labels for an alert is what uniquely identifies an alert within Grafana Alerts. -- The Alertmanager uses labels to match alerts for [silences]({{< relref "../silences/" >}}) and [alert groups]({{< relref "../alert-groups/" >}}) in [notification policies]({{< relref "../notification-policies/" >}}). +- The Alertmanager uses labels to match alerts for [silences]({{< relref "../silences/" >}}) and [alert groups]({{< relref "../alert-groups/" >}}) in [notification policies]({{< relref "../notifications/_index.md" >}}). - The alerting UI displays labels for every alert instance generated by the evaluation of that rule. - Contact points can access labels to dynamically generate notifications that contain information specific to the alert that is resulting in a notification. - Labels can be added to an [alerting rule]({{< relref "../alerting-rules/" >}}). These manually configured labels are able to use template functions and reference other labels. Labels added to an alerting rule here take precedence in the event of a collision between labels. diff --git a/docs/sources/alerting/unified-alerting/notifications/_index.md b/docs/sources/alerting/unified-alerting/notifications/_index.md index ba1cbf0b638..3a263fb8143 100644 --- a/docs/sources/alerting/unified-alerting/notifications/_index.md +++ b/docs/sources/alerting/unified-alerting/notifications/_index.md @@ -9,7 +9,7 @@ weight = 450 Notification policies determine how alerts are routed to contact points. Policies have a tree structure, where each policy can have one or more child policies. Each policy, except for the root policy, can also match specific alert labels. Each alert is evaluated by the root policy and subsequently by each child policy. If you enable the `Continue matching subsequent sibling nodes` option is enabled for a specific policy, then evaluation continues even after one or more matches. A parent policy’s configuration settings and contact point information govern the behavior of an alert that does not match any of the child policies. A root policy governs any alert that does not match a specific policy. -You can configure Grafana managed notification policies as well as notification policies for an [external Alertmanager data source]({{< relref "../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "./fundamentals/alertmanager.md" >}}). +You can configure Grafana managed notification policies as well as notification policies for an [external Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "../fundamentals/alertmanager.md" >}}). ## Grouping @@ -33,7 +33,7 @@ You can configure grouping to be `group_by: [alertname]` (take note that the `en 1. Click **Notification policies**. 1. From the **Alertmanager** dropdown, select an external Alertmanager. By default, the Grafana Alertmanager is selected. 1. In the Root policy section, click **Edit** (pen icon). -1. In **Default contact point**, update the [contact point]({{< relref "./contact-points.md" >}}) to whom notifications should be sent for rules when alert rules do not match any specific policy. +1. In **Default contact point**, update the [contact point]({{< relref "../contact-points.md" >}}) to whom notifications should be sent for rules when alert rules do not match any specific policy. 1. In **Group by**, choose labels to group alerts by. If multiple alerts are matched for this policy, then they are grouped by these labels. A notification is sent per group. If the field is empty (default), then all notifications are sent in a single group. Use a special label `...` to group alerts by all labels (which effectively disables grouping). 1. In **Timing options**, select from the following options: - **Group wait** Time to wait to buffer alerts of the same group before sending an initial notification. Default is 30 seconds. @@ -48,7 +48,7 @@ You can configure grouping to be `group_by: [alertname]` (take note that the `en 1. From the **Alertmanager** dropdown, select an Alertmanager. By default, the Grafana Alertmanager is selected. 1. To add a top level specific policy, go to the **Specific routing** section and click **New specific policy**. 1. In **Matching labels** section, add one or more rules for matching alert labels. For more information, see ["How label matching works"](#how-label-matching-works). -1. In **Contact point**, add the [contact point]({{< relref "./contact-points.md" >}}) to send notification to if alert matches only this specific policy and not any of the nested policies. +1. In **Contact point**, add the [contact point]({{< relref "../contact-points.md" >}}) to send notification to if alert matches only this specific policy and not any of the nested policies. 1. Optionally, enable **Continue matching subsequent sibling nodes** to continue matching nested policies even after the alert matched the parent policy. When this option is enabled, you can get more than one notification. Use it to send notification to a catch-all contact point as well as to one of more specific contact points handled by nested policies. 1. Optionally, enable **Override grouping** to specify the same grouping as the root policy. If this option is not enabled, the root policy grouping is used. 1. Optionally, enable **Override general timings** to override the timing options configured in the group notification policy. diff --git a/docs/sources/alerting/unified-alerting/notifications/mute-timings.md b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md index 254a9a0d26b..411f9fdb479 100644 --- a/docs/sources/alerting/unified-alerting/notifications/mute-timings.md +++ b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md @@ -11,7 +11,7 @@ A mute timing is a recurring interval of time when no new notifications for a po Similar to silences, mute timings do not prevent alert rules from being evaluated, nor do they stop alert instances from being shown in the user interface. They only prevent notifications from being created. -You can configure Grafana managed mute timings as well as mute timings for an [external Alertmanager data source]({{< relref "../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "./fundamentals/alertmanager.md" >}}). +You can configure Grafana managed mute timings as well as mute timings for an [external Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "../fundamentals/alertmanager.md" >}}). ## Mute timings vs silences diff --git a/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md b/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md index bb0b333e998..c4a8e4e0bda 100644 --- a/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md +++ b/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md @@ -291,16 +291,15 @@ When configured, Grafana will pass the user's token to the plugin in an Authoriz ```go func (ds *dataSource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - for _, q := range req.Queries { - token := strings.Fields(q.Headers.Get("Authorization")) + token := strings.Fields(req.Headers["Authorization"]) + var ( + tokenType = token[0] + accessToken = token[1] + ) - var ( - tokenType = token[0] - accessToken = token[1] - ) - - // ... - } + for _, q := range req.Queries { + // ... + } } ``` diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index 51b33a7d632..3e19c75196f 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -31,7 +31,7 @@ If the data source does not support loading full range log volume histogram, the For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level. -> **Tip:** If you use Loki data source and the "level" is in you log content, try to use parsers (JSON, logfmt, regex,..) to extract level information into level label that is used to determine log level. +> **Tip:** If you use Loki data source and the "level" is in your log-line, use parsers (JSON, logfmt, regex,..) to extract the level information into a level label that is used to determine log level. This will allow the histogram to show the various log levels in separate bars. **Supported log levels and mapping of log level abbreviation and expressions:** diff --git a/docs/sources/http_api/dashboard_versions.md b/docs/sources/http_api/dashboard_versions.md index 7e301d390b3..23494e40e0e 100644 --- a/docs/sources/http_api/dashboard_versions.md +++ b/docs/sources/http_api/dashboard_versions.md @@ -67,7 +67,7 @@ Status Codes: ## Get dashboard version -`GET /api/dashboards/id/:dashboardId/versions/:id` +`GET /api/dashboards/id/:dashboardId/versions/:version` Get the dashboard version with the given version, for the dashboard with the given id. diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index 0cbb62a40e0..8cb3c83df60 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,6 +8,7 @@ weight = 10000 Here you can find detailed release notes that list everything that is included in every release as well as notices about deprecations, breaking changes as well as changes that relate to plugin development. +- [Release notes for 8.4.2]({{< relref "release-notes-8-4-2" >}}) - [Release notes for 8.4.1]({{< relref "release-notes-8-4-1" >}}) - [Release notes for 8.4.0-beta1]({{< relref "release-notes-8-4-0-beta1" >}}) - [Release notes for 8.3.5]({{< relref "release-notes-8-3-5" >}}) diff --git a/docs/sources/release-notes/release-notes-8-4-2.md b/docs/sources/release-notes/release-notes-8-4-2.md new file mode 100644 index 00000000000..760462b592f --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-2.md @@ -0,0 +1,17 @@ ++++ +title = "Release notes for Grafana 8.4.2" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.2 + +### Features and enhancements + +- **OAuth:** Add setting to skip org assignment for external users. [#34834](https://github.com/grafana/grafana/pull/34834), [@baez90](https://github.com/baez90) +- **Tracing:** Add option to map tag names to log label names in trace to logs settings. [#45178](https://github.com/grafana/grafana/pull/45178), [@connorlindsey](https://github.com/connorlindsey) + +### Bug fixes + +- **Explore:** Fix closing split pane when logs panel is used. [#45602](https://github.com/grafana/grafana/pull/45602), [@ifrost](https://github.com/ifrost) diff --git a/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index bf6782e2d64..39c87b73448 100644 --- a/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -5,7 +5,7 @@ e2e.scenario({ itName: 'Tests dashboard links and variables in links', addScenarioDataSource: false, addScenarioDashBoard: false, - skipScenario: false, + skipScenario: true, // Skipped because it was causing many failures in main. scenario: () => { e2e.flows.openDashboard({ uid: 'yBCC3aKGk' }); e2e() diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index 8b2fea28f97..aaf400ba105 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -140,14 +140,14 @@ export const fieldReducers = new Registry(() => [ standard: true, reduce: calculateLast, }, - { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, { id: ReducerID.firstNotNull, - name: 'First', + name: 'First *', description: 'First non-null value', standard: true, reduce: calculateFirstNotNull, }, + { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true }, { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true }, { id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, aliasIds: ['avg'] }, diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index 96af4c4a602..a95d14fc42e 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -229,4 +229,4 @@ export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorM }; export const escapeUnescapedString = (string: string) => - string.replace(/\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); + string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx index d979d8b6aa2..63c1aba06c9 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -21,24 +21,46 @@ export interface Props extends ButtonProps { const dummyClearFunc = () => {}; export function ClipboardButton({ onClipboardCopy, onClipboardError, children, getText, ...buttonProps }: Props) { - // Can be removed in 9.x const buttonRef = useRef(null); - const copyText = useCallback(() => { - const copiedText = getText(); + const copyTextCallback = useCallback(async () => { + const textToCopy = getText(); + // Can be removed in 9.x const dummyEvent: ClipboardEvent = { action: 'copy', clearSelection: dummyClearFunc, - text: copiedText, + text: textToCopy, trigger: buttonRef.current!, }; - navigator.clipboard - .writeText(copiedText) - .then(() => (onClipboardCopy?.(dummyEvent), () => onClipboardError?.(dummyEvent))); + try { + await copyText(textToCopy, buttonRef); + onClipboardCopy?.(dummyEvent); + } catch { + onClipboardError?.(dummyEvent); + } }, [getText, onClipboardCopy, onClipboardError]); return ( - ); } + +const copyText = async (text: string, buttonRef: React.MutableRefObject) => { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } else { + // Use a fallback method for browsers/contexts that don't support the Clipboard API. + // See https://web.dev/async-clipboard/#feature-detection. + const input = document.createElement('input'); + // Normally we'd append this to the body. However if we're inside a focus manager + // from react-aria, we can't focus anything outside of the managed area. + // Instead, let's append it to the button. Then we're guaranteed to be able to focus + copy. + buttonRef.current?.appendChild(input); + input.value = text; + input.focus(); + input.select(); + document.execCommand('copy'); + input.remove(); + } +}; diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 8f736f79070..353c40cea8f 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -441,7 +441,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { } m.Use(middleware.Recovery(hs.Cfg)) - m.UseMiddleware(middleware.CSRF(hs.Cfg.LoginCookieName)) + m.UseMiddleware(middleware.CSRF(hs.Cfg.LoginCookieName, hs.log)) hs.mapStatic(m, hs.Cfg.StaticRootPath, "build", "public/build") hs.mapStatic(m, hs.Cfg.StaticRootPath, "", "public", "/public/views/swagger.html") diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go index 830d1ba1f1c..3ac96cbd8c4 100644 --- a/pkg/expr/classic/classic.go +++ b/pkg/expr/classic/classic.go @@ -129,7 +129,7 @@ func (ccc *ConditionsCmd) Execute(ctx context.Context, vars mathexp.Vars) (mathe } thisCondFiring := firingCount > 0 - thisCondNoData := nilReducedCount > 0 + thisCondNoData := len(querySeriesSet.Values) == nilReducedCount if i == 0 { firing = thisCondFiring @@ -144,7 +144,7 @@ func (ccc *ConditionsCmd) Execute(ctx context.Context, vars mathexp.Vars) (mathe noDataFound = noDataFound && thisCondNoData } - if len(querySeriesSet.Values) == nilReducedCount { + if thisCondNoData { matches = append(matches, EvalMatch{ Metric: "NoData", }) diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index 5e96826f1d8..623ae19f3bc 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -169,6 +169,31 @@ func TestConditionsCmdExecute(t *testing.T) { return v }, }, + { + name: "single query and single condition - empty series and not empty series", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + valBasedSeries(), + valBasedSeries(ptr.Float64(3)), + }, + }, + }, + conditionsCmd: &ConditionsCmd{ + Conditions: []condition{ + { + QueryRefID: "A", + Reducer: classicReducer("avg"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: .5}, + }, + }}, + resultNumber: func() mathexp.Number { + v := valBasedNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(3)}}) + return v + }, + }, { name: "single query and two conditions", vars: mathexp.Vars{ diff --git a/pkg/expr/classic/reduce_test.go b/pkg/expr/classic/reduce_test.go index 50871dd61cd..ea2bc7cbbb3 100644 --- a/pkg/expr/classic/reduce_test.go +++ b/pkg/expr/classic/reduce_test.go @@ -102,6 +102,12 @@ func TestReducer(t *testing.T) { inputSeries: valBasedSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), expectedNumber: valBasedNumber(ptr.Float64(2)), }, + { + name: "count_non_null with mixed null/real values", + reducer: classicReducer("count_non_null"), + inputSeries: valBasedSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), + expectedNumber: valBasedNumber(ptr.Float64(2)), + }, { name: "count_non_null with no values", reducer: classicReducer("count_non_null"), diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index bc70d09779d..7bce53f5666 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -4,10 +4,12 @@ import ( "errors" "net/http" "net/url" - "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/util" ) -func CSRF(loginCookieName string) func(http.Handler) http.Handler { +func CSRF(loginCookieName string, logger log.Logger) func(http.Handler) http.Handler { // As per RFC 7231/4.2.2 these methods are idempotent: // (GET is excluded because it may have side effects in some APIs) safeMethods := []string{"HEAD", "OPTIONS", "TRACE"} @@ -27,12 +29,21 @@ func CSRF(loginCookieName string) func(http.Handler) http.Handler { } } // Otherwise - verify that Origin matches the server origin - host := strings.Split(r.Host, ":")[0] + netAddr, err := util.SplitHostPortDefault(r.Host, "", "0") // we ignore the port + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + origin, err := url.Parse(r.Header.Get("Origin")) - if err != nil || (origin.String() != "" && origin.Hostname() != host) { + if err != nil { + logger.Error("error parsing Origin header", "err", err) + } + if err != nil || netAddr.Host == "" || (origin.String() != "" && origin.Hostname() != netAddr.Host) { http.Error(w, "origin not allowed", http.StatusForbidden) return } + next.ServeHTTP(w, r) }) } diff --git a/pkg/middleware/csrf_test.go b/pkg/middleware/csrf_test.go new file mode 100644 index 00000000000..312356cce3f --- /dev/null +++ b/pkg/middleware/csrf_test.go @@ -0,0 +1,124 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/stretchr/testify/require" +) + +func TestMiddlewareCSRF(t *testing.T) { + tests := []struct { + name string + cookieName string + method string + origin string + host string + code int + }{ + { + name: "mismatched origin and host is forbidden", + cookieName: "foo", + method: "GET", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusForbidden, + }, + { + name: "mismatched origin and host is NOT forbidden with a 'Safe Method'", + cookieName: "foo", + method: "TRACE", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusOK, + }, + { + name: "mismatched origin and host is NOT forbidden without a cookie", + cookieName: "", + method: "GET", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusOK, + }, + { + name: "malformed host is a bad request", + cookieName: "foo", + method: "GET", + host: "localhost:80:80", + code: http.StatusBadRequest, + }, + { + name: "host works without port", + cookieName: "foo", + method: "GET", + host: "localhost", + origin: "http://localhost", + code: http.StatusOK, + }, + { + name: "port does not have to match", + cookieName: "foo", + method: "GET", + host: "localhost:80", + origin: "http://localhost:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host works with port", + cookieName: "foo", + method: "GET", + host: "[::1]:3000", + origin: "http://[::1]:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host (with longer address) works with port", + cookieName: "foo", + method: "GET", + host: "[2001:db8::1]:3000", + origin: "http://[2001:db8::1]:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host (with longer address) works without port", + cookieName: "foo", + method: "GET", + host: "[2001:db8::1]", + origin: "http://[2001:db8::1]", + code: http.StatusOK, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := csrfScenario(t, tt.cookieName, tt.method, tt.origin, tt.host) + require.Equal(t, tt.code, rr.Code) + }) + } +} + +func csrfScenario(t *testing.T, cookieName, method, origin, host string) *httptest.ResponseRecorder { + req, err := http.NewRequest(method, "/", nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(&http.Cookie{ + Name: cookieName, + }) + + // Note: Not sure where host header populates req.Host, or how that works. + req.Host = host + req.Header.Set("HOST", host) + + req.Header.Set("ORIGIN", origin) + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + }) + + rr := httptest.NewRecorder() + handler := CSRF(cookieName, log.New())(testHandler) + handler.ServeHTTP(rr, req) + return rr +} diff --git a/pkg/services/ngalert/notifier/available_channels.go b/pkg/services/ngalert/notifier/available_channels.go index 5a5e8eeffaa..2ac4ec04b04 100644 --- a/pkg/services/ngalert/notifier/available_channels.go +++ b/pkg/services/ngalert/notifier/available_channels.go @@ -459,6 +459,14 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { PropertyName: "url", Secure: true, }, + { // New in 8.4. + Label: "Endpoint URL", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + Description: "Optionally provide a custom Slack message API endpoint for non-webhook requests, default is https://slack.com/api/chat.postMessage", + Placeholder: "Slack endpoint url", + PropertyName: "endpointUrl", + }, { // New in 8.0. Label: "Title", Element: alerting.ElementTypeInput, @@ -589,7 +597,7 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { }, { Type: "webhook", - Name: "webhook", + Name: "Webhook", Description: "Sends HTTP POST request to a URL", Heading: "Webhook settings", Options: []alerting.NotifierOption{ diff --git a/pkg/services/ngalert/notifier/channels/default_template_test.go b/pkg/services/ngalert/notifier/channels/default_template_test.go index 4e61ac59015..e2eee8533e0 100644 --- a/pkg/services/ngalert/notifier/channels/default_template_test.go +++ b/pkg/services/ngalert/notifier/channels/default_template_test.go @@ -99,7 +99,7 @@ Labels: Annotations: - ann1 = annv1 Source: http://localhost/alert1 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1 Dashboard: http://localhost/grafana/d/dbuid123 Panel: http://localhost/grafana/d/dbuid123?viewPanel=puid123 @@ -110,7 +110,7 @@ Labels: Annotations: - ann1 = annv2 Source: http://localhost/alert2 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2 **Resolved** @@ -122,7 +122,7 @@ Labels: Annotations: - ann1 = annv3 Source: http://localhost/alert3 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval3 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3 Dashboard: http://localhost/grafana/d/dbuid456 Panel: http://localhost/grafana/d/dbuid456?viewPanel=puid456 @@ -133,7 +133,7 @@ Labels: Annotations: - ann1 = annv4 Source: http://localhost/alert4 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval4 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4 `, }, { @@ -150,7 +150,7 @@ Annotations: Source: http://localhost/alert1 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1 Dashboard: http://localhost/grafana/d/dbuid123 @@ -168,7 +168,7 @@ Annotations: Source: http://localhost/alert2 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2 @@ -185,7 +185,7 @@ Annotations: Source: http://localhost/alert3 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval3 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3 Dashboard: http://localhost/grafana/d/dbuid456 @@ -203,7 +203,7 @@ Annotations: Source: http://localhost/alert4 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval4 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4 `, diff --git a/pkg/services/ngalert/notifier/channels/dingding_test.go b/pkg/services/ngalert/notifier/channels/dingding_test.go index a868eca502e..64df838fdcf 100644 --- a/pkg/services/ngalert/notifier/channels/dingding_test.go +++ b/pkg/services/ngalert/notifier/channels/dingding_test.go @@ -44,7 +44,7 @@ func TestDingdingNotifier(t *testing.T) { "msgtype": "link", "link": map[string]interface{}{ "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", - "text": "**Firing**\n\nValue: 1234\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: 1234\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "title": "[FIRING:1] (val1)", }, }, diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index 51a89589e64..95454a1f79e 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -43,7 +43,7 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ @@ -123,7 +123,7 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index 42a738ba196..9b15518978b 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -87,7 +87,7 @@ func TestEmailNotifier(t *testing.T) { Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, Annotations: template.KV{"runbook_url": "http://fix.me"}, Fingerprint: "15a37193dce72bab", - SilenceURL: "http://localhost/base/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DAlwaysFiring%2Cseverity%3Dwarning", + SilenceURL: "http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DAlwaysFiring&matcher=severity%3Dwarning", DashboardURL: "http://localhost/base/d/abc", PanelURL: "http://localhost/base/d/abc?viewPanel=5", }, diff --git a/pkg/services/ngalert/notifier/channels/googlechat_test.go b/pkg/services/ngalert/notifier/channels/googlechat_test.go index 15a70034dfe..527d77be16f 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat_test.go +++ b/pkg/services/ngalert/notifier/channels/googlechat_test.go @@ -58,7 +58,7 @@ func TestGoogleChatNotifier(t *testing.T) { Widgets: []widget{ textParagraphWidget{ Text: text{ - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, }, buttonWidget{ @@ -117,7 +117,7 @@ func TestGoogleChatNotifier(t *testing.T) { Widgets: []widget{ textParagraphWidget{ Text: text{ - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", }, }, buttonWidget{ diff --git a/pkg/services/ngalert/notifier/channels/kafka_test.go b/pkg/services/ngalert/notifier/channels/kafka_test.go index 9e30bfebdd1..34bf944e592 100644 --- a/pkg/services/ngalert/notifier/channels/kafka_test.go +++ b/pkg/services/ngalert/notifier/channels/kafka_test.go @@ -51,7 +51,7 @@ func TestKafkaNotifier(t *testing.T) { "client": "Grafana", "client_url": "http://localhost/alerting/list", "description": "[FIRING:1] (val1)", - "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" } } @@ -86,7 +86,7 @@ func TestKafkaNotifier(t *testing.T) { "client": "Grafana", "client_url": "http://localhost/alerting/list", "description": "[FIRING:2] ", - "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", "incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" } } diff --git a/pkg/services/ngalert/notifier/channels/line_test.go b/pkg/services/ngalert/notifier/channels/line_test.go index 7d6a50cfd81..467e6bf3621 100644 --- a/pkg/services/ngalert/notifier/channels/line_test.go +++ b/pkg/services/ngalert/notifier/channels/line_test.go @@ -46,7 +46,7 @@ func TestLineNotifier(t *testing.T) { "Authorization": "Bearer sometoken", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", }, - expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A", + expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A", expMsgError: nil, }, { name: "Multiple alerts", @@ -68,7 +68,7 @@ func TestLineNotifier(t *testing.T) { "Authorization": "Bearer sometoken", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", }, - expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A", + expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval2%0A", expMsgError: nil, }, { name: "Token missing", diff --git a/pkg/services/ngalert/notifier/channels/opsgenie_test.go b/pkg/services/ngalert/notifier/channels/opsgenie_test.go index c45ad66ff6b..5dbb8db481a 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie_test.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie_test.go @@ -44,7 +44,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "details": { "url": "http://localhost/alerting/list" }, @@ -69,7 +69,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", "details": { "url": "http://localhost/alerting/list" }, @@ -94,7 +94,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", "details": { "alertname": "alert1", "lbl1": "val1", @@ -126,7 +126,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", "details": { "alertname": "alert1", "url": "http://localhost/alerting/list" diff --git a/pkg/services/ngalert/notifier/channels/pagerduty_test.go b/pkg/services/ngalert/notifier/channels/pagerduty_test.go index 3703b83910f..44e1e3bffc6 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty_test.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty_test.go @@ -59,7 +59,7 @@ func TestPagerdutyNotifier(t *testing.T) { Component: "Grafana", Group: "default", CustomDetails: map[string]string{ - "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "num_firing": "1", "num_resolved": "0", "resolved": "", @@ -105,7 +105,7 @@ func TestPagerdutyNotifier(t *testing.T) { Component: "My Grafana", Group: "my_group", CustomDetails: map[string]string{ - "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", "num_firing": "2", "num_resolved": "0", "resolved": "", diff --git a/pkg/services/ngalert/notifier/channels/pushover_test.go b/pkg/services/ngalert/notifier/channels/pushover_test.go index 2b81a43079d..b9543720ff2 100644 --- a/pkg/services/ngalert/notifier/channels/pushover_test.go +++ b/pkg/services/ngalert/notifier/channels/pushover_test.go @@ -59,7 +59,7 @@ func TestPushoverNotifier(t *testing.T) { "title": "[FIRING:1] (val1)", "url": "http://localhost/alerting/list", "url_title": "Show alert rule", - "message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "html": "1", }, expMsgError: nil, diff --git a/pkg/services/ngalert/notifier/channels/sensugo_test.go b/pkg/services/ngalert/notifier/channels/sensugo_test.go index 2277f09aed5..271265c1d30 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo_test.go +++ b/pkg/services/ngalert/notifier/channels/sensugo_test.go @@ -60,7 +60,7 @@ func TestSensuGoNotifier(t *testing.T) { "ruleURL": "http://localhost/alerting/list", }, }, - "output": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "output": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "issued": timeNow().Unix(), "interval": 86400, "status": 2, diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 66713312ed8..e77ef66afec 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -52,9 +52,11 @@ func NewSlackNotifier(model *NotificationChannelConfig, t *template.Template, fn return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} } + endpointURL := model.Settings.Get("endpointUrl").MustString(SlackAPIEndpoint) + slackURL := fn(context.Background(), model.SecureSettings, "url", model.Settings.Get("url").MustString()) if slackURL == "" { - slackURL = SlackAPIEndpoint + slackURL = endpointURL } apiURL, err := url.Parse(slackURL) if err != nil { @@ -62,7 +64,7 @@ func NewSlackNotifier(model *NotificationChannelConfig, t *template.Template, fn } recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString()) - if recipient == "" && apiURL.String() == SlackAPIEndpoint { + if recipient == "" && apiURL.String() == endpointURL { return nil, receiverInitError{Cfg: *model, Reason: "recipient must be specified when using the Slack chat API", } diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index ce6eef5bba8..62419671619 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -59,7 +59,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "[FIRING:1] (val1)", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, @@ -94,7 +94,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "[FIRING:1] (val1)", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, @@ -136,7 +136,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "2 firing, 0 resolved", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", Fallback: "2 firing, 0 resolved", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, @@ -160,6 +160,42 @@ func TestSlackNotifier(t *testing.T) { }`, expInitError: `failed to validate receiver "slack_testing" of type "slack": recipient must be specified when using the Slack chat API`, }, + { + name: "Custom endpoint url", + settings: `{ + "token": "1234", + "recipient": "#testchannel", + "endpointUrl": "https://slack-custom.com/api/", + "icon_emoji": ":emoji:" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, + }, + expMsg: &slackMessage{ + Channel: "#testchannel", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/assets/img/fav32.png", + Color: "#D63232", + Ts: 0, + }, + }, + }, + expMsgError: nil, + }, } for _, c := range cases { @@ -196,6 +232,12 @@ func TestSlackNotifier(t *testing.T) { _ = request.Body.Close() }() + url := settingsJSON.Get("url").MustString() + if len(url) == 0 { + endpointUrl := settingsJSON.Get("endpointUrl").MustString(SlackAPIEndpoint) + require.Equal(t, endpointUrl, request.URL.String()) + } + b, err := io.ReadAll(request.Body) require.NoError(t, err) body = string(b) diff --git a/pkg/services/ngalert/notifier/channels/teams_test.go b/pkg/services/ngalert/notifier/channels/teams_test.go index 92bf8e15fdd..7f743f6fe91 100644 --- a/pkg/services/ngalert/notifier/channels/teams_test.go +++ b/pkg/services/ngalert/notifier/channels/teams_test.go @@ -49,7 +49,7 @@ func TestTeamsNotifier(t *testing.T) { "sections": []map[string]interface{}{ { "title": "Details", - "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, }, "potentialAction": []map[string]interface{}{ diff --git a/pkg/services/ngalert/notifier/channels/telegram_test.go b/pkg/services/ngalert/notifier/channels/telegram_test.go index 1e7d5f24a20..c3d7c9ba921 100644 --- a/pkg/services/ngalert/notifier/channels/telegram_test.go +++ b/pkg/services/ngalert/notifier/channels/telegram_test.go @@ -48,7 +48,7 @@ func TestTelegramNotifier(t *testing.T) { expMsg: map[string]string{ "chat_id": "someid", "parse_mode": "html", - "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, expMsgError: nil, }, { @@ -75,7 +75,7 @@ func TestTelegramNotifier(t *testing.T) { expMsg: map[string]string{ "chat_id": "someid", "parse_mode": "html", - "text": "__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "text": "__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", }, expMsgError: nil, }, { diff --git a/pkg/services/ngalert/notifier/channels/template_data.go b/pkg/services/ngalert/notifier/channels/template_data.go index 1533866b90a..10cd9c6bc47 100644 --- a/pkg/services/ngalert/notifier/channels/template_data.go +++ b/pkg/services/ngalert/notifier/channels/template_data.go @@ -99,7 +99,15 @@ func extendAlert(alert template.Alert, externalURL string, logger log.Logger) *E } sort.Strings(matchers) u.Path = path.Join(externalPath, "/alerting/silence/new") - u.RawQuery = "alertmanager=grafana&matchers=" + url.QueryEscape(strings.Join(matchers, ",")) + + query := make(url.Values) + query.Add("alertmanager", "grafana") + for _, matcher := range matchers { + query.Add("matcher", matcher) + } + + u.RawQuery = query.Encode() + extended.SilenceURL = u.String() return extended diff --git a/pkg/services/ngalert/notifier/channels/threema_test.go b/pkg/services/ngalert/notifier/channels/threema_test.go index 01e4f530411..47364e677f2 100644 --- a/pkg/services/ngalert/notifier/channels/threema_test.go +++ b/pkg/services/ngalert/notifier/channels/threema_test.go @@ -45,7 +45,7 @@ func TestThreemaNotifier(t *testing.T) { }, }, }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", + expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", expMsgError: nil, }, { name: "Multiple alerts", @@ -67,7 +67,7 @@ func TestThreemaNotifier(t *testing.T) { }, }, }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", + expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval2%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", expMsgError: nil, }, { name: "Invalid gateway id", diff --git a/pkg/services/ngalert/notifier/channels/victorops_test.go b/pkg/services/ngalert/notifier/channels/victorops_test.go index 2db53055964..3f11e25429b 100644 --- a/pkg/services/ngalert/notifier/channels/victorops_test.go +++ b/pkg/services/ngalert/notifier/channels/victorops_test.go @@ -47,7 +47,7 @@ func TestVictoropsNotifier(t *testing.T) { "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", "message_type": "CRITICAL", "monitoring_tool": "Grafana v" + setting.BuildVersion, - "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, expMsgError: nil, }, { @@ -72,7 +72,7 @@ func TestVictoropsNotifier(t *testing.T) { "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", "message_type": "CRITICAL", "monitoring_tool": "Grafana v" + setting.BuildVersion, - "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", }, expMsgError: nil, }, { diff --git a/pkg/services/ngalert/notifier/channels/webhook_test.go b/pkg/services/ngalert/notifier/channels/webhook_test.go index e043abc4757..97de78fe33c 100644 --- a/pkg/services/ngalert/notifier/channels/webhook_test.go +++ b/pkg/services/ngalert/notifier/channels/webhook_test.go @@ -68,7 +68,7 @@ func TestWebhookNotifier(t *testing.T) { Fingerprint: "fac0861a85de433a", DashboardURL: "http://localhost/d/abcd", PanelURL: "http://localhost/d/abcd?viewPanel=efgh", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1", + SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", }, }, GroupLabels: template.KV{ @@ -87,7 +87,7 @@ func TestWebhookNotifier(t *testing.T) { GroupKey: "alertname", Title: "[FIRING:1] (val1)", State: "alerting", - Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", OrgID: orgID, }, expMsgError: nil, @@ -137,7 +137,7 @@ func TestWebhookNotifier(t *testing.T) { "ann1": "annv1", }, Fingerprint: "fac0861a85de433a", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1", + SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", }, { Status: "firing", Labels: template.KV{ @@ -148,7 +148,7 @@ func TestWebhookNotifier(t *testing.T) { "ann1": "annv2", }, Fingerprint: "fab6861a85d5eeb5", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2", + SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2", }, }, GroupLabels: template.KV{ @@ -165,7 +165,7 @@ func TestWebhookNotifier(t *testing.T) { TruncatedAlerts: 1, Title: "[FIRING:2] ", State: "alerting", - Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", OrgID: orgID, }, expMsgError: nil, diff --git a/pkg/services/ngalert/notifier/channels/wecom_test.go b/pkg/services/ngalert/notifier/channels/wecom_test.go index 46676e9b656..77b045107b9 100644 --- a/pkg/services/ngalert/notifier/channels/wecom_test.go +++ b/pkg/services/ngalert/notifier/channels/wecom_test.go @@ -45,7 +45,7 @@ func TestWeComNotifier(t *testing.T) { }, expMsg: map[string]interface{}{ "markdown": map[string]interface{}{ - "content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", + "content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", }, "msgtype": "markdown", }, diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index dd72ee0db9f..7d607778021 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -61,7 +61,7 @@ type schedule struct { clock clock.Clock - heartbeat *alerting.Ticker + ticker *alerting.Ticker // evalApplied is only used for tests: test code can set it to non-nil // function, and then it'll be called from the event loop whenever the @@ -130,7 +130,7 @@ func NewScheduler(cfg SchedulerCfg, expressionService *expr.Service, appURL *url clock: cfg.C, baseInterval: cfg.BaseInterval, log: cfg.Logger, - heartbeat: ticker, + ticker: ticker, evalAppliedFunc: cfg.EvalAppliedFunc, stopAppliedFunc: cfg.StopAppliedFunc, evaluator: cfg.Evaluator, @@ -157,7 +157,7 @@ func (sch *schedule) Pause() error { if sch == nil { return fmt.Errorf("scheduler is not initialised") } - sch.heartbeat.Pause() + sch.ticker.Pause() sch.log.Info("alert rule scheduler paused", "now", sch.clock.Now()) return nil } @@ -166,7 +166,7 @@ func (sch *schedule) Unpause() error { if sch == nil { return fmt.Errorf("scheduler is not initialised") } - sch.heartbeat.Unpause() + sch.ticker.Unpause() sch.log.Info("alert rule scheduler unpaused", "now", sch.clock.Now()) return nil } @@ -367,8 +367,12 @@ func (sch *schedule) schedulePeriodic(ctx context.Context) error { dispatcherGroup, ctx := errgroup.WithContext(ctx) for { select { - case tick := <-sch.heartbeat.C: - start := time.Now() + case tick := <-sch.ticker.C: + // We use Round(0) on the start time to remove the monotonic clock. + // This is required as ticks from the ticker and time.Now() can have + // a monotonic clock that when subtracted do not represent the delta + // in wall clock time. + start := time.Now().Round(0) sch.metrics.BehindSeconds.Set(start.Sub(tick).Seconds()) tickNum := tick.Unix() / int64(sch.baseInterval.Seconds()) @@ -468,7 +472,7 @@ func (sch *schedule) schedulePeriodic(ctx context.Context) error { } } -func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRuleKey, evalCh <-chan *evalContext, updateCh <-chan struct{}) error { +func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRuleKey, evalCh <-chan *evaluation, updateCh <-chan struct{}) error { logger := sch.log.New("uid", key.UID, "org", key.OrgID) logger.Debug("alert rule routine started") @@ -541,16 +545,16 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul return q.Result, nil } - evaluate := func(ctx context.Context, alertRule *models.AlertRule, attempt int64, evalCtx *evalContext) error { - logger := logger.New("version", alertRule.Version, "attempt", attempt, "now", evalCtx.now) + evaluate := func(ctx context.Context, r *models.AlertRule, attempt int64, e *evaluation) error { + logger := logger.New("version", r.Version, "attempt", attempt, "now", e.scheduledAt) start := sch.clock.Now() condition := models.Condition{ - Condition: alertRule.Condition, - OrgID: alertRule.OrgID, - Data: alertRule.Data, + Condition: r.Condition, + OrgID: r.OrgID, + Data: r.Data, } - results, err := sch.evaluator.ConditionEval(&condition, evalCtx.now, sch.expressionService) + results, err := sch.evaluator.ConditionEval(&condition, e.scheduledAt, sch.expressionService) dur := sch.clock.Now().Sub(start) evalTotal.Inc() evalDuration.Observe(dur.Seconds()) @@ -562,7 +566,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul } logger.Debug("alert rule evaluated", "results", results, "duration", dur) - processedStates := sch.stateManager.ProcessEvalResults(ctx, alertRule, results) + processedStates := sch.stateManager.ProcessEvalResults(ctx, r, results) sch.saveAlertStates(ctx, processedStates) alerts := FromAlertStateToPostableAlerts(processedStates, sch.stateManager, sch.appURL) @@ -616,7 +620,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul evalRunning = true defer func() { evalRunning = false - sch.evalApplied(key, ctx.now) + sch.evalApplied(key, ctx.scheduledAt) }() err := retryIfError(func(attempt int64) error { @@ -741,7 +745,7 @@ func (r *alertRuleRegistry) keyMap() map[models.AlertRuleKey]struct{} { } type alertRuleInfo struct { - evalCh chan *evalContext + evalCh chan *evaluation updateCh chan struct{} ctx context.Context stop context.CancelFunc @@ -749,15 +753,15 @@ type alertRuleInfo struct { func newAlertRuleInfo(parent context.Context) *alertRuleInfo { ctx, cancel := context.WithCancel(parent) - return &alertRuleInfo{evalCh: make(chan *evalContext), updateCh: make(chan struct{}), ctx: ctx, stop: cancel} + return &alertRuleInfo{evalCh: make(chan *evaluation), updateCh: make(chan struct{}), ctx: ctx, stop: cancel} } // eval signals the rule evaluation routine to perform the evaluation of the rule. Does nothing if the loop is stopped func (a *alertRuleInfo) eval(t time.Time, version int64) bool { select { - case a.evalCh <- &evalContext{ - now: t, - version: version, + case a.evalCh <- &evaluation{ + scheduledAt: t, + version: version, }: return true case <-a.ctx.Done(): @@ -775,16 +779,16 @@ func (a *alertRuleInfo) update() bool { } } -type evalContext struct { - now time.Time - version int64 +type evaluation struct { + scheduledAt time.Time + version int64 } // overrideCfg is only used on tests. func (sch *schedule) overrideCfg(cfg SchedulerCfg) { sch.clock = cfg.C sch.baseInterval = cfg.BaseInterval - sch.heartbeat = alerting.NewTicker(cfg.C.Now(), time.Second*0, cfg.C, int64(cfg.BaseInterval.Seconds())) + sch.ticker = alerting.NewTicker(cfg.C.Now(), time.Second*0, cfg.C, int64(cfg.BaseInterval.Seconds())) sch.evalAppliedFunc = cfg.EvalAppliedFunc sch.stopAppliedFunc = cfg.StopAppliedFunc } diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 69b2f6bf961..6555f11da95 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -361,7 +361,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { for _, evalState := range normalStates { // TODO rewrite when we are able to mock/fake state manager t.Run(fmt.Sprintf("when rule evaluation happens (evaluation state %s)", evalState), func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) sch, ruleStore, instanceStore, _, reg := createSchedule(evalAppliedChan) @@ -375,9 +375,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { expectedTime := time.UnixMicro(rand.Int63()) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime := waitForTimeChannel(t, evalAppliedChan) @@ -467,7 +467,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) go func() { - err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evalContext), make(chan struct{})) + err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evaluation), make(chan struct{})) stoppedChan <- err }() @@ -478,7 +478,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { }) t.Run("should fetch rule from database only if new version is greater than current", func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) ctx := context.Background() @@ -493,9 +493,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { }() expectedTime := time.UnixMicro(rand.Int63()) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime := waitForTimeChannel(t, evalAppliedChan) @@ -508,9 +508,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { // and call with new version expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) - evalChan <- &evalContext{ - now: expectedTime, - version: newRule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: newRule.Version, } actualTime = waitForTimeChannel(t, evalAppliedChan) @@ -531,7 +531,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { }) t.Run("should not fetch rule if version is equal or less than current", func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) sch, ruleStore, _, _, _ := createSchedule(evalAppliedChan) @@ -545,9 +545,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { }() expectedTime := time.UnixMicro(rand.Int63()) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime := waitForTimeChannel(t, evalAppliedChan) @@ -555,17 +555,17 @@ func TestSchedule_ruleRoutine(t *testing.T) { // try again with the same version expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime = waitForTimeChannel(t, evalAppliedChan) require.Equal(t, expectedTime, actualTime) expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version - 1, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version - 1, } actualTime = waitForTimeChannel(t, evalAppliedChan) require.Equal(t, expectedTime, actualTime) @@ -582,7 +582,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { t.Run("when update channel is not empty", func(t *testing.T) { t.Run("should fetch the alert rule from database", func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) updateChan := make(chan struct{}) @@ -612,9 +612,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { require.Equal(t, rule.OrgID, m.OrgID) // now call evaluation loop to make sure that the rule was persisted - evalChan <- &evalContext{ - now: time.UnixMicro(rand.Int63()), - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: time.UnixMicro(rand.Int63()), + version: rule.Version, } waitForTimeChannel(t, evalAppliedChan) @@ -637,7 +637,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { go func() { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), make(chan *evalContext), updateChan) + _ = sch.ruleRoutine(ctx, rule.GetKey(), make(chan *evaluation), updateChan) }() ruleStore.hook = func(cmd interface{}) error { @@ -677,7 +677,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { return len(s.Alertmanagers()) == 1 }, 20*time.Second, 200*time.Millisecond, "external Alertmanager was not discovered.") - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) updateChan := make(chan struct{}) @@ -783,7 +783,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { return len(s.Alertmanagers()) == 1 }, 20*time.Second, 200*time.Millisecond, "external Alertmanager was not discovered.") - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) sch, ruleStore, _, _, _ := createSchedule(evalAppliedChan) @@ -797,9 +797,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan struct{})) }() - evalChan <- &evalContext{ - now: time.Now(), - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: time.Now(), + version: rule.Version, } waitForTimeChannel(t, evalAppliedChan) @@ -843,7 +843,7 @@ func TestSchedule_alertRuleInfo(t *testing.T) { select { case ctx := <-r.evalCh: require.Equal(t, version, ctx.version) - require.Equal(t, expected, ctx.now) + require.Equal(t, expected, ctx.scheduledAt) require.True(t, <-resultCh) case <-time.After(5 * time.Second): t.Fatal("No message was received on eval channel") diff --git a/pkg/services/ngalert/schedule/testing.go b/pkg/services/ngalert/schedule/testing.go index fbae64209f0..cf12c2aa895 100644 --- a/pkg/services/ngalert/schedule/testing.go +++ b/pkg/services/ngalert/schedule/testing.go @@ -438,6 +438,12 @@ func NewFakeAnnotationsRepo() *FakeAnnotationsRepo { } } +func (repo *FakeAnnotationsRepo) Items() []*annotations.Item { + repo.mtx.Lock() + defer repo.mtx.Unlock() + return repo.items +} + func (repo *FakeAnnotationsRepo) Len() int { repo.mtx.Lock() defer repo.mtx.Unlock() diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index cc6ef325953..bb8e30d33e4 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -5,18 +5,19 @@ import ( "fmt" "net/url" "strconv" + "strings" "time" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/log" - + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/sqlstore" ) var ResendDelay = 30 * time.Second @@ -185,7 +186,7 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu st.set(currentState) if oldState != currentState.State { - go st.createAlertAnnotation(ctx, currentState.State, alertRule, result, oldState) + go st.annotateState(ctx, alertRule, currentState.Labels, result.EvaluatedAt, currentState.State, oldState) } return currentState } @@ -233,18 +234,19 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State { } } -func (st *Manager) createAlertAnnotation(ctx context.Context, new eval.State, alertRule *ngModels.AlertRule, result eval.Result, oldState eval.State) { - st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", new.String(), "oldState", oldState.String()) +func (st *Manager) annotateState(ctx context.Context, alertRule *ngModels.AlertRule, labels data.Labels, evaluatedAt time.Time, state eval.State, previousState eval.State) { + st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", state.String(), "oldState", previousState.String()) - annotationText := fmt.Sprintf("%s {%s} - %s", alertRule.Title, result.Instance.String(), new.String()) + labels = removePrivateLabels(labels) + annotationText := fmt.Sprintf("%s {%s} - %s", alertRule.Title, labels.String(), state.String()) item := &annotations.Item{ AlertId: alertRule.ID, OrgId: alertRule.OrgID, - PrevState: oldState.String(), - NewState: new.String(), + PrevState: previousState.String(), + NewState: state.String(), Text: annotationText, - Epoch: result.EvaluatedAt.UnixNano() / int64(time.Millisecond), + Epoch: evaluatedAt.UnixNano() / int64(time.Millisecond), } dashUid, ok := alertRule.Annotations[ngModels.DashboardUIDAnnotation] @@ -295,6 +297,10 @@ func (st *Manager) staleResultsHandler(ctx context.Context, alertRule *ngModels. if err = st.instanceStore.DeleteAlertInstance(ctx, s.OrgID, s.AlertRuleUID, labelsHash); err != nil { st.log.Error("unable to delete stale instance from database", "error", err.Error(), "orgID", s.OrgID, "alertRuleUID", s.AlertRuleUID, "cacheID", s.CacheId) } + + if s.State == eval.Alerting { + st.annotateState(ctx, alertRule, s.Labels, time.Now(), eval.Normal, s.State) + } } } } @@ -302,3 +308,13 @@ func (st *Manager) staleResultsHandler(ctx context.Context, alertRule *ngModels. func isItStale(lastEval time.Time, intervalSeconds int64) bool { return lastEval.Add(2 * time.Duration(intervalSeconds) * time.Second).Before(time.Now()) } + +func removePrivateLabels(labels data.Labels) data.Labels { + result := make(data.Labels) + for k, v := range labels { + if !strings.HasPrefix(k, "__") && !strings.HasSuffix(k, "__") { + result[k] = v + } + } + return result +} diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 70ea4b58500..99d6f5ee447 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "testing" "time" @@ -27,6 +28,52 @@ import ( var testMetrics = metrics.NewNGAlert(prometheus.NewPedanticRegistry()) +func TestDashboardAnnotations(t *testing.T) { + evaluationTime, err := time.Parse("2006-01-02", "2022-01-01") + require.NoError(t, err) + + ctx := context.Background() + _, dbstore := tests.SetupTestEnv(t, 1) + + st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore) + + fakeAnnoRepo := schedule.NewFakeAnnotationsRepo() + annotations.SetRepository(fakeAnnoRepo) + + const mainOrgID int64 = 1 + + rule := tests.CreateTestAlertRuleWithLabels(t, ctx, dbstore, 600, mainOrgID, map[string]string{ + "test1": "testValue1", + "test2": "{{ $labels.instance_label }}", + }) + + st.Warm(ctx) + _ = st.ProcessEvalResults(ctx, rule, eval.Results{{ + Instance: data.Labels{"instance_label": "testValue2"}, + State: eval.Alerting, + EvaluatedAt: evaluationTime, + }}) + + expected := []string{rule.Title + " {alertname=" + rule.Title + ", instance_label=testValue2, test1=testValue1, test2=testValue2} - Alerting"} + sort.Strings(expected) + require.Eventuallyf(t, func() bool { + var actual []string + for _, next := range fakeAnnoRepo.Items() { + actual = append(actual, next.Text) + } + sort.Strings(actual) + if len(expected) != len(actual) { + return false + } + for i := 0; i < len(expected); i++ { + if expected[i] != actual[i] { + return false + } + } + return true + }, time.Second, 100*time.Millisecond, "unexpected annotations") +} + func TestProcessEvalResults(t *testing.T) { evaluationTime, err := time.Parse("2006-01-02", "2021-03-25") if err != nil { @@ -503,7 +550,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, - expectedAnnotations: 2, + expectedAnnotations: 3, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -529,7 +576,7 @@ func TestProcessEvalResults(t *testing.T) { Values: make(map[string]*float64), }, }, - StartsAt: evaluationTime, + StartsAt: evaluationTime.Add(20 * time.Second), EndsAt: evaluationTime.Add(30 * time.Second).Add(state.ResendDelay * 3), LastEvaluationTime: evaluationTime.Add(30 * time.Second), EvaluationDuration: evaluationDuration, diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 2947f789761..6b06eb1a453 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -64,7 +64,7 @@ func (a *State) resultAlerting(alertRule *ngModels.AlertRule, result eval.Result case eval.Alerting: a.setEndsAt(alertRule, result) case eval.Pending: - if result.EvaluatedAt.Sub(a.StartsAt) > alertRule.For { + if result.EvaluatedAt.Sub(a.StartsAt) >= alertRule.For { a.State = eval.Alerting a.StartsAt = result.EvaluatedAt a.setEndsAt(alertRule, result) diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 3e11cb1c722..3158b39d6c2 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -52,6 +52,10 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * // CreateTestAlertRule creates a dummy alert definition to be used by the tests. func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBstore, intervalSeconds int64, orgID int64) *models.AlertRule { + return CreateTestAlertRuleWithLabels(t, ctx, dbstore, intervalSeconds, orgID, nil) +} + +func CreateTestAlertRuleWithLabels(t *testing.T, ctx context.Context, dbstore *store.DBstore, intervalSeconds int64, orgID int64, labels map[string]string) *models.AlertRule { ruleGroup := fmt.Sprintf("ruleGroup-%s", util.GenerateShortUID()) err := dbstore.UpdateRuleGroup(ctx, store.UpdateRuleGroupCmd{ OrgID: orgID, @@ -62,6 +66,7 @@ func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBsto Rules: []apimodels.PostableExtendedRuleNode{ { ApiRuleNode: &apimodels.ApiRuleNode{ + Labels: labels, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, }, GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go index dbd01ad238b..759ca3584c2 100644 --- a/pkg/tests/api/alerting/api_available_channel_test.go +++ b/pkg/tests/api/alerting/api_available_channel_test.go @@ -1,6 +1,7 @@ package alerting import ( + "encoding/json" "fmt" "io/ioutil" "net/http" @@ -11,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/tests/testinfra" ) @@ -45,1654 +47,9 @@ func TestAvailableChannels(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) - require.JSONEq(t, expAvailableChannelJsonOutput, string(b)) -} -var expAvailableChannelJsonOutput = ` -[ - { - "type": "dingding", - "name": "DingDing", - "heading": "DingDing settings", - "description": "Sends HTTP POST request to DingDing", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Message Type", - "description": "", - "placeholder": "", - "propertyName": "msgType", - "selectOptions": [ - { - "value": "link", - "label": "Link" - }, - { - "value": "actionCard", - "label": "ActionCard" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "kafka", - "name": "Kafka REST Proxy", - "heading": "Kafka settings", - "description": "Sends notifications to Kafka Rest Proxy", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Kafka REST Proxy", - "description": "", - "placeholder": "http://localhost:8082", - "propertyName": "kafkaRestProxy", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Topic", - "description": "", - "placeholder": "topic1", - "propertyName": "kafkaTopic", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "email", - "name": "Email", - "heading": "Email settings", - "description": "Sends notifications using Grafana server configured SMTP settings", - "info": "", - "options": [ - { - "element": "checkbox", - "inputType": "", - "label": "Single email", - "description": "Send a single email to all recipients", - "placeholder": "", - "propertyName": "singleEmail", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Addresses", - "description": "You can enter multiple email addresses using a \";\" separator", - "placeholder": "", - "propertyName": "addresses", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "Optional message to include with the email. You can use template variables", - "placeholder": "", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "pagerduty", - "name": "PagerDuty", - "heading": "PagerDuty settings", - "description": "Sends notifications to PagerDuty", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Integration Key", - "description": "", - "placeholder": "Pagerduty Integration Key", - "propertyName": "integrationKey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "select", - "inputType": "", - "label": "Severity", - "description": "", - "placeholder": "", - "propertyName": "severity", - "selectOptions": [ - { - "value": "critical", - "label": "Critical" - }, - { - "value": "error", - "label": "Error" - }, - { - "value": "warning", - "label": "Warning" - }, - { - "value": "info", - "label": "Info" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Class", - "description": "The class/type of the event, for example 'ping failure' or 'cpu load'", - "placeholder": "", - "propertyName": "class", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Component", - "description": "Component of the source machine that is responsible for the event, for example mysql or eth0", - "placeholder": "Grafana", - "propertyName": "component", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Group", - "description": "Logical grouping of components of a service, for example 'app-stack'", - "placeholder": "", - "propertyName": "group", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Summary", - "description": "You can use templates for summary", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "summary", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "victorops", - "name": "VictorOps", - "heading": "VictorOps settings", - "description": "Sends notifications to VictorOps", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "VictorOps url", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Message Type", - "description": "", - "placeholder": "", - "propertyName": "messageType", - "selectOptions": [ - { - "value": "CRITICAL", - "label": "CRITICAL" - }, - { - "value": "WARNING", - "label": "WARNING" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "pushover", - "name": "Pushover", - "description": "Sends HTTP POST request to the Pushover API", - "heading": "Pushover settings", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "API Token", - "description": "", - "placeholder": "Application token", - "propertyName": "apiToken", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "User key(s)", - "description": "", - "placeholder": "comma-separated list", - "propertyName": "userKey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Device(s) (optional)", - "description": "", - "placeholder": "comma-separated list; leave empty to send to all devices", - "propertyName": "device", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Alerting priority", - "description": "", - "placeholder": "", - "propertyName": "priority", - "selectOptions": [ - { - "value": "2", - "label": "Emergency" - }, - { - "value": "1", - "label": "High" - }, - { - "value": "0", - "label": "Normal" - }, - { - "value": "-1", - "label": "Low" - }, - { - "value": "-2", - "label": "Lowest" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "OK priority", - "description": "", - "placeholder": "", - "propertyName": "okPriority", - "selectOptions": [ - { - "value": "2", - "label": "Emergency" - }, - { - "value": "1", - "label": "High" - }, - { - "value": "0", - "label": "Normal" - }, - { - "value": "-1", - "label": "Low" - }, - { - "value": "-2", - "label": "Lowest" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Retry (Only used for Emergency Priority)", - "description": "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.", - "placeholder": "minimum 30 seconds", - "propertyName": "retry", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Expire (Only used for Emergency Priority)", - "description": "How many seconds the alerting or OK notification will continue to be retried.", - "placeholder": "maximum 86400 seconds", - "propertyName": "expire", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Alerting sound", - "description": "", - "placeholder": "", - "propertyName": "sound", - "selectOptions": [ - { - "value": "default", - "label": "Default" - }, - { - "value": "pushover", - "label": "Pushover" - }, - { - "value": "bike", - "label": "Bike" - }, - { - "value": "bugle", - "label": "Bugle" - }, - { - "value": "cashregister", - "label": "Cashregister" - }, - { - "value": "classical", - "label": "Classical" - }, - { - "value": "cosmic", - "label": "Cosmic" - }, - { - "value": "falling", - "label": "Falling" - }, - { - "value": "gamelan", - "label": "Gamelan" - }, - { - "value": "incoming", - "label": "Incoming" - }, - { - "value": "intermission", - "label": "Intermission" - }, - { - "value": "magic", - "label": "Magic" - }, - { - "value": "mechanical", - "label": "Mechanical" - }, - { - "value": "pianobar", - "label": "Pianobar" - }, - { - "value": "siren", - "label": "Siren" - }, - { - "value": "spacealarm", - "label": "Spacealarm" - }, - { - "value": "tugboat", - "label": "Tugboat" - }, - { - "value": "alien", - "label": "Alien" - }, - { - "value": "climb", - "label": "Climb" - }, - { - "value": "persistent", - "label": "Persistent" - }, - { - "value": "echo", - "label": "Echo" - }, - { - "value": "updown", - "label": "Updown" - }, - { - "value": "none", - "label": "None" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "OK sound", - "description": "", - "placeholder": "", - "propertyName": "okSound", - "selectOptions": [ - { - "value": "default", - "label": "Default" - }, - { - "value": "pushover", - "label": "Pushover" - }, - { - "value": "bike", - "label": "Bike" - }, - { - "value": "bugle", - "label": "Bugle" - }, - { - "value": "cashregister", - "label": "Cashregister" - }, - { - "value": "classical", - "label": "Classical" - }, - { - "value": "cosmic", - "label": "Cosmic" - }, - { - "value": "falling", - "label": "Falling" - }, - { - "value": "gamelan", - "label": "Gamelan" - }, - { - "value": "incoming", - "label": "Incoming" - }, - { - "value": "intermission", - "label": "Intermission" - }, - { - "value": "magic", - "label": "Magic" - }, - { - "value": "mechanical", - "label": "Mechanical" - }, - { - "value": "pianobar", - "label": "Pianobar" - }, - { - "value": "siren", - "label": "Siren" - }, - { - "value": "spacealarm", - "label": "Spacealarm" - }, - { - "value": "tugboat", - "label": "Tugboat" - }, - { - "value": "alien", - "label": "Alien" - }, - { - "value": "climb", - "label": "Climb" - }, - { - "value": "persistent", - "label": "Persistent" - }, - { - "value": "echo", - "label": "Echo" - }, - { - "value": "updown", - "label": "Updown" - }, - { - "value": "none", - "label": "None" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "slack", - "name": "Slack", - "heading": "Slack settings", - "description": "Sends notifications to Slack", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Recipient", - "description": "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook", - "placeholder": "", - "propertyName": "recipient", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Token", - "description": "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook", - "placeholder": "", - "propertyName": "token", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Username", - "description": "Set the username for the bot's message", - "placeholder": "", - "propertyName": "username", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Icon emoji", - "description": "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.", - "placeholder": "", - "propertyName": "icon_emoji", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Icon URL", - "description": "Provide a URL to an image to use as the icon for the bot's message", - "placeholder": "", - "propertyName": "icon_url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Mention Users", - "description": "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)", - "placeholder": "", - "propertyName": "mentionUsers", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Mention Groups", - "description": "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)", - "placeholder": "", - "propertyName": "mentionGroups", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Mention Channel", - "description": "Mention whole channel or just active members when notifying", - "placeholder": "", - "propertyName": "mentionChannel", - "selectOptions": [ - { - "value": "", - "label": "Disabled" - }, - { - "value": "here", - "label": "Every active channel member" - }, - { - "value": "channel", - "label": "Every channel member" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Webhook URL", - "description": "Optionally provide a Slack incoming webhook URL for sending messages, in this case the token isn't necessary", - "placeholder": "Slack incoming webhook URL", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Title", - "description": "Templated title of the slack message", - "placeholder": "{{ template \"slack.default.title\" . }}", - "propertyName": "title", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Text Body", - "description": "Body of the slack message", - "placeholder": "{{ template \"slack.default.text\" . }}", - "propertyName": "text", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "sensugo", - "name": "Sensu Go", - "description": "Sends HTTP POST request to a Sensu Go API", - "heading": "Sensu Go Settings", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Backend URL", - "description": "", - "placeholder": "http://sensu-api.local:8080", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "password", - "label": "API Key", - "description": "API key to auth to Sensu Go backend", - "placeholder": "", - "propertyName": "apikey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Proxy entity name", - "description": "", - "placeholder": "default", - "propertyName": "entity", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Check name", - "description": "", - "placeholder": "default", - "propertyName": "check", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Handler", - "description": "", - "placeholder": "", - "propertyName": "handler", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Namespace", - "description": "", - "placeholder": "default", - "propertyName": "namespace", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "teams", - "name": "Microsoft Teams", - "heading": "Teams settings", - "description": "Sends notifications using Incoming Webhook connector to Microsoft Teams", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "URL", - "description": "", - "placeholder": "Teams incoming webhook url", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "telegram", - "name": "Telegram", - "heading": "Telegram API settings", - "description": "Sends notifications to Telegram", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "BOT API Token", - "description": "", - "placeholder": "Telegram BOT API Token", - "propertyName": "bottoken", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Chat ID", - "description": "Integer Telegram Chat Identifier", - "placeholder": "", - "propertyName": "chatid", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "webhook", - "name": "webhook", - "heading": "Webhook settings", - "description": "Sends HTTP POST request to a URL", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Http Method", - "description": "", - "placeholder": "", - "propertyName": "httpMethod", - "selectOptions": [ - { - "value": "POST", - "label": "POST" - }, - { - "value": "PUT", - "label": "PUT" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Username", - "description": "", - "placeholder": "", - "propertyName": "username", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "password", - "label": "Password", - "description": "", - "placeholder": "", - "propertyName": "password", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Max Alerts", - "description": "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.", - "placeholder": "", - "propertyName": "maxAlerts", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "wecom", - "name": "WeCom", - "heading": "WeCom settings", - "description": "Send alerts generated by Grafana to WeCom", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "Custom WeCom message. You can use template variables.", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "prometheus-alertmanager", - "name": "Alertmanager", - "heading": "Alertmanager Settings", - "description": "Sends notifications to Alertmanager", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "URL", - "description": "", - "placeholder": "http://localhost:9093", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Basic Auth User", - "description": "", - "placeholder": "", - "propertyName": "basicAuthUser", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "input", - "inputType": "password", - "label": "Basic Auth Password", - "description": "", - "placeholder": "", - "propertyName": "basicAuthPassword", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": true - } - ] - }, - { - "type": "discord", - "name": "Discord", - "heading": "Discord settings", - "description": "Sends notifications to Discord", - "info": "", - "options": [ - { - "label": "Message Content", - "description": "Mention a group using @ or a user using <@ID> when notifying in a channel", - "element": "input", - "inputType": "text", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "label": "Webhook URL", - "description": "", - "element": "input", - "inputType": "text", - "placeholder": "Discord webhook URL", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "label": "Avatar URL", - "description": "", - "element": "input", - "inputType": "text", - "placeholder": "", - "propertyName": "avatar_url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "checkbox", - "inputType": "", - "label": "Use Discord's Webhook Username", - "description": "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", - "placeholder": "", - "propertyName": "use_discord_username", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "googlechat", - "name": "Google Hangouts Chat", - "heading": "Google Hangouts Chat settings", - "description": "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message format", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Url", - "description": "", - "placeholder": "Google Hangouts Chat incoming webhook url", - "propertyName": "url", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "textarea", - "inputType": "", - "label": "Message", - "description": "", - "placeholder": "{{ template \"default.message\" . }}", - "propertyName": "message", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - }, - { - "type": "LINE", - "name": "LINE", - "heading": "LINE notify settings", - "description": "Send notifications to LINE notify", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Token", - "description": "", - "placeholder": "LINE notify token key", - "propertyName": "token", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - } - ] - }, - { - "type": "threema", - "name": "Threema Gateway", - "heading": "Threema Gateway settings", - "description": "Sends notifications to Threema using Threema Gateway (Basic IDs)", - "info": "Notifications can be configured for any Threema Gateway ID of type \"Basic\". End-to-End IDs are not currently supported.The Threema Gateway ID can be set up at https://gateway.threema.ch/.", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "Gateway ID", - "description": "Your 8 character Threema Gateway Basic ID (starting with a *).", - "placeholder": "*3MAGWID", - "propertyName": "gateway_id", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "\\*[0-9A-Z]{7}", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "Recipient ID", - "description": "The 8 character Threema ID that should receive the alerts.", - "placeholder": "YOUR3MID", - "propertyName": "recipient_id", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "[0-9A-Z]{8}", - "secure": false - }, - { - "element": "input", - "inputType": "text", - "label": "API Secret", - "description": "Your Threema Gateway API secret.", - "placeholder": "", - "propertyName": "api_secret", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - } - ] - }, - { - "type": "opsgenie", - "name": "OpsGenie", - "heading": "OpsGenie settings", - "description": "Sends notifications to OpsGenie", - "info": "", - "options": [ - { - "element": "input", - "inputType": "text", - "label": "API Key", - "description": "", - "placeholder": "OpsGenie API Key", - "propertyName": "apiKey", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": true - }, - { - "element": "input", - "inputType": "text", - "label": "Alert API Url", - "description": "", - "placeholder": "https://api.opsgenie.com/v2/alerts", - "propertyName": "apiUrl", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": true, - "validationRule": "", - "secure": false - }, - { - "element": "checkbox", - "inputType": "", - "label": "Auto close incidents", - "description": "Automatically close alerts in OpsGenie once the alert goes back to ok.", - "placeholder": "", - "propertyName": "autoClose", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "checkbox", - "inputType": "", - "label": "Override priority", - "description": "Allow the alert priority to be set using the og_priority annotation", - "placeholder": "", - "propertyName": "overridePriority", - "selectOptions": null, - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - }, - { - "element": "select", - "inputType": "", - "label": "Send notification tags as", - "description": "Send the common annotations to Opsgenie as either Extra Properties, Tags or both", - "placeholder": "", - "propertyName": "sendTagsAs", - "selectOptions": [ - { - "value": "tags", - "label": "Tags" - }, - { - "value": "details", - "label": "Extra Properties" - }, - { - "value": "both", - "label": "Tags & Extra Properties" - } - ], - "showWhen": { - "field": "", - "is": "" - }, - "required": false, - "validationRule": "", - "secure": false - } - ] - } -] -` + expNotifiers := notifier.GetAvailableNotifiers() + expJson, err := json.Marshal(expNotifiers) + require.NoError(t, err) + require.Equal(t, string(expJson), string(b)) +} diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index e519221ca75..78794631adb 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2090,7 +2090,7 @@ var expEmailNotifications = []*models.SendEmailCommandSync{ EndsAt: time.Time{}, GeneratorURL: "http://localhost:3000/alerting/UID_EmailAlert/edit", Fingerprint: "08c220aa26cd0cf5", - SilenceURL: "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DEmailAlert", + SilenceURL: "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DEmailAlert", DashboardURL: "", PanelURL: "", ValueString: "[ var='A' labels={} value=1 ]", @@ -2148,7 +2148,7 @@ var expNonEmailNotifications = map[string][]string{ { "title": "[FIRING:1] SlackAlert2 ", "title_link": "http://localhost:3000/alerting/list", - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SlackAlert2\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SlackAlert2/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSlackAlert2\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SlackAlert2\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SlackAlert2/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DSlackAlert2\n", "fallback": "[FIRING:1] SlackAlert2 ", "footer": "Grafana v", "footer_icon": "https://grafana.com/assets/img/fav32.png", @@ -2181,7 +2181,7 @@ var expNonEmailNotifications = map[string][]string{ "component": "Integration Test", "group": "testgroup", "custom_details": { - "firing": "\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PagerdutyAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PagerdutyAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DPagerdutyAlert\n", + "firing": "\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PagerdutyAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PagerdutyAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DPagerdutyAlert\n", "num_firing": "1", "num_resolved": "0", "resolved": "" @@ -2201,7 +2201,7 @@ var expNonEmailNotifications = map[string][]string{ `{ "link": { "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%3A3000%2Falerting%2Flist", - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DingDingAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DingDingAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDingDingAlert\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DingDingAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DingDingAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DDingDingAlert\n", "title": "[FIRING:1] DingDingAlert " }, "msgtype": "link" @@ -2226,7 +2226,7 @@ var expNonEmailNotifications = map[string][]string{ ], "sections": [ { - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TeamsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TeamsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTeamsAlert\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TeamsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TeamsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTeamsAlert\n", "title": "Details" } ], @@ -2252,7 +2252,7 @@ var expNonEmailNotifications = map[string][]string{ "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "http://localhost:3000/alerting/UID_WebhookAlert/edit", "fingerprint": "929467973978d053", - "silenceURL": "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DWebhookAlert", + "silenceURL": "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DWebhookAlert", "dashboardURL": "", "panelURL": "" } @@ -2270,12 +2270,12 @@ var expNonEmailNotifications = map[string][]string{ "truncatedAlerts": 0, "title": "[FIRING:1] WebhookAlert ", "state": "alerting", - "message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = WebhookAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_WebhookAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DWebhookAlert\n" + "message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = WebhookAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_WebhookAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DWebhookAlert\n" }`, }, "discord_recv/discord_test": { `{ - "content": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DiscordAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DiscordAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDiscordAlert\n", + "content": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DiscordAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DiscordAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DDiscordAlert\n", "embeds": [ { "color": 14037554, @@ -2303,7 +2303,7 @@ var expNonEmailNotifications = map[string][]string{ }, "name": "default" }, - "output": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SensuGoAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SensuGoAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSensuGoAlert\n", + "output": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SensuGoAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SensuGoAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DSensuGoAlert\n", "status": 2 }, "entity": { @@ -2316,10 +2316,10 @@ var expNonEmailNotifications = map[string][]string{ }`, }, "pushover_recv/pushover_test": { - "--abcd\r\nContent-Disposition: form-data; name=\"user\"\r\n\r\nmysecretkey\r\n--abcd\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\nmysecrettoken\r\n--abcd\r\nContent-Disposition: form-data; name=\"priority\"\r\n\r\n0\r\n--abcd\r\nContent-Disposition: form-data; name=\"sound\"\r\n\r\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n[FIRING:1] PushoverAlert \r\n--abcd\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\nhttp://localhost:3000/alerting/list\r\n--abcd\r\nContent-Disposition: form-data; name=\"url_title\"\r\n\r\nShow alert rule\r\n--abcd\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PushoverAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PushoverAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DPushoverAlert\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"html\"\r\n\r\n1\r\n--abcd--\r\n", + "--abcd\r\nContent-Disposition: form-data; name=\"user\"\r\n\r\nmysecretkey\r\n--abcd\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\nmysecrettoken\r\n--abcd\r\nContent-Disposition: form-data; name=\"priority\"\r\n\r\n0\r\n--abcd\r\nContent-Disposition: form-data; name=\"sound\"\r\n\r\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n[FIRING:1] PushoverAlert \r\n--abcd\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\nhttp://localhost:3000/alerting/list\r\n--abcd\r\nContent-Disposition: form-data; name=\"url_title\"\r\n\r\nShow alert rule\r\n--abcd\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PushoverAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PushoverAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DPushoverAlert\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"html\"\r\n\r\n1\r\n--abcd--\r\n", }, "telegram_recv/bot6sh027hs034h": { - "--abcd\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\ntelegram_chat_id\r\n--abcd\r\nContent-Disposition: form-data; name=\"parse_mode\"\r\n\r\nhtml\r\n--abcd\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TelegramAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TelegramAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTelegramAlert\n\r\n--abcd--\r\n", + "--abcd\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\ntelegram_chat_id\r\n--abcd\r\nContent-Disposition: form-data; name=\"parse_mode\"\r\n\r\nhtml\r\n--abcd\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TelegramAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TelegramAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTelegramAlert\n\r\n--abcd--\r\n", }, "googlechat_recv/googlechat_test": { `{ @@ -2335,7 +2335,7 @@ var expNonEmailNotifications = map[string][]string{ "widgets": [ { "textParagraph": { - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = GoogleChatAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_GoogleChatAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DGoogleChatAlert\n" + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = GoogleChatAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_GoogleChatAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DGoogleChatAlert\n" } }, { @@ -2373,7 +2373,7 @@ var expNonEmailNotifications = map[string][]string{ "client": "Grafana", "client_url": "http://localhost:3000/alerting/list", "description": "[FIRING:1] KafkaAlert ", - "details": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = KafkaAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_KafkaAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DKafkaAlert\n", + "details": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = KafkaAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_KafkaAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DKafkaAlert\n", "incident_key": "35c0bdb1715f9162a20d7b2a01cb2e3a4c5b1dc663571701e3f67212b696332f" } } @@ -2381,10 +2381,10 @@ var expNonEmailNotifications = map[string][]string{ }`, }, "line_recv/line_test": { - `message=%5BFIRING%3A1%5D+LineAlert+%0Ahttp%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+LineAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_LineAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DLineAlert%0A`, + `message=%5BFIRING%3A1%5D+LineAlert+%0Ahttp%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+LineAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_LineAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253DLineAlert%0A`, }, "threema_recv/threema_test": { - `from=%2A1234567&secret=myapisecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D+ThreemaAlert+%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+ThreemaAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_ThreemaAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DThreemaAlert%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A&to=abcdefgh`, + `from=%2A1234567&secret=myapisecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D+ThreemaAlert+%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+ThreemaAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_ThreemaAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253DThreemaAlert%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A&to=abcdefgh`, }, "victorops_recv/victorops_test": { `{ @@ -2393,14 +2393,14 @@ var expNonEmailNotifications = map[string][]string{ "entity_id": "633ae988fa7074bcb51f3d1c5fef2ba1c5c4ccb45b3ecbf681f7d507b078b1ae", "message_type": "CRITICAL", "monitoring_tool": "Grafana v", - "state_message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = VictorOpsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_VictorOpsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DVictorOpsAlert\n", + "state_message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = VictorOpsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_VictorOpsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DVictorOpsAlert\n", "timestamp": %s }`, }, "opsgenie_recv/opsgenie_test": { `{ "alias": "47e92f0f6ef9fe99f3954e0d6155f8d09c4b9a038d8c3105e82c0cee4c62956e", - "description": "[FIRING:1] OpsGenieAlert \nhttp://localhost:3000/alerting/list\n\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = OpsGenieAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_OpsGenieAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DOpsGenieAlert\n", + "description": "[FIRING:1] OpsGenieAlert \nhttp://localhost:3000/alerting/list\n\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = OpsGenieAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_OpsGenieAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DOpsGenieAlert\n", "details": { "url": "http://localhost:3000/alerting/list" }, diff --git a/pkg/tests/api/metrics/api_metrics_test.go b/pkg/tests/api/metrics/api_metrics_test.go deleted file mode 100644 index 02d6cea7383..00000000000 --- a/pkg/tests/api/metrics/api_metrics_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package metrics - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "testing" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" - "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch" - - cwapi "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQueryCloudWatchMetrics(t *testing.T) { - grafDir, cfgPath := testinfra.CreateGrafDir(t) - - addr, sqlStore := testinfra.StartGrafana(t, grafDir, cfgPath) - setUpDatabase(t, sqlStore) - - origNewCWClient := cloudwatch.NewCWClient - t.Cleanup(func() { - cloudwatch.NewCWClient = origNewCWClient - }) - var client cloudwatch.FakeCWClient - cloudwatch.NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { - return client - } - - t.Run("Custom metrics", func(t *testing.T) { - client = cloudwatch.FakeCWClient{ - Metrics: []*cwapi.Metric{ - { - MetricName: aws.String("Test_MetricName"), - Dimensions: []*cwapi.Dimension{ - { - Name: aws.String("Test_DimensionName"), - }, - }, - }, - }, - } - - req := dtos.MetricRequest{ - Queries: []*simplejson.Json{ - simplejson.NewFromAny(map[string]interface{}{ - "type": "metricFindQuery", - "subtype": "metrics", - "region": "us-east-1", - "namespace": "custom", - "datasourceId": 1, - }), - }, - } - result := makeCWRequest(t, req, addr) - - dataFrames := data.Frames{ - &data.Frame{ - RefID: "A", - Fields: []*data.Field{ - data.NewField("text", nil, []string{"Test_MetricName"}), - data.NewField("value", nil, []string{"Test_MetricName"}), - }, - Meta: &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": float64(1), - }, - }, - }, - } - - expect := backend.NewQueryDataResponse() - expect.Responses["A"] = backend.DataResponse{ - Frames: dataFrames, - } - assert.Equal(t, *expect, result) - }) -} - -func TestQueryCloudWatchLogs(t *testing.T) { - grafDir, cfgPath := testinfra.CreateGrafDir(t) - addr, store := testinfra.StartGrafana(t, grafDir, cfgPath) - setUpDatabase(t, store) - - origNewCWLogsClient := cloudwatch.NewCWLogsClient - t.Cleanup(func() { - cloudwatch.NewCWLogsClient = origNewCWLogsClient - }) - - var client cloudwatch.FakeCWLogsClient - cloudwatch.NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return client - } - - t.Run("Describe log groups", func(t *testing.T) { - client = cloudwatch.FakeCWLogsClient{} - - req := dtos.MetricRequest{ - Queries: []*simplejson.Json{ - simplejson.NewFromAny(map[string]interface{}{ - "type": "logAction", - "subtype": "DescribeLogGroups", - "region": "us-east-1", - "datasourceId": 1, - }), - }, - } - tr := makeCWRequest(t, req, addr) - - dataFrames := data.Frames{ - &data.Frame{ - Name: "logGroups", - RefID: "A", - Fields: []*data.Field{ - data.NewField("logGroupName", nil, []*string{}), - }, - }, - } - - expect := backend.NewQueryDataResponse() - expect.Responses["A"] = backend.DataResponse{ - Frames: dataFrames, - } - assert.Equal(t, *expect, tr) - }) -} - -func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.QueryDataResponse { - t.Helper() - - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - err := enc.Encode(&req) - require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ds/query", addr) - t.Logf("Making POST request to %s", u) - // nolint:gosec - resp, err := http.Post(u, "application/json", &buf) - require.NoError(t, err) - require.NotNil(t, resp) - t.Cleanup(func() { - err := resp.Body.Close() - assert.NoError(t, err) - }) - - buf = bytes.Buffer{} - _, err = io.Copy(&buf, resp.Body) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - - var tr backend.QueryDataResponse - err = json.Unmarshal(buf.Bytes(), &tr) - require.NoError(t, err) - - return tr -} - -func setUpDatabase(t *testing.T, store *sqlstore.SQLStore) { - t.Helper() - - err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - _, err := sess.Insert(&models.DataSource{ - Id: 1, - // This will be the ID of the main org - OrgId: 2, - Name: "Test", - Type: "cloudwatch", - Created: time.Now(), - Updated: time.Now(), - }) - return err - }) - require.NoError(t, err) - - // Make sure changes are synced with other goroutines - err = store.Sync() - require.NoError(t, err) -} diff --git a/pkg/tsdb/cloudwatch/annotation_query_test.go b/pkg/tsdb/cloudwatch/annotation_query_test.go index 6f3d7c71d6b..ad947c538c9 100644 --- a/pkg/tsdb/cloudwatch/annotation_query_test.go +++ b/pkg/tsdb/cloudwatch/annotation_query_test.go @@ -97,10 +97,3 @@ func TestQuery_AnnotationQuery(t *testing.T) { }, client.calls.describeAlarms[0]) }) } - -func pointerString(s string) *string { - return &s -} -func pointerInt64(i int64) *int64 { - return &i -} diff --git a/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go b/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go index 46612e4ed43..dc9c3893077 100644 --- a/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go +++ b/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go @@ -12,10 +12,10 @@ import ( "github.com/stretchr/testify/require" ) -var counter = 1 - type cloudWatchFakeClient struct { cloudwatchiface.CloudWatchAPI + + counterForGetMetricDataWithContext int } func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { @@ -23,13 +23,13 @@ func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, in res := []*cloudwatch.MetricDataResult{{ Values: []*float64{aws.Float64(12.3), aws.Float64(23.5)}, }} - if counter == 0 { + if client.counterForGetMetricDataWithContext == 0 { nextToken = "" res = []*cloudwatch.MetricDataResult{{ Values: []*float64{aws.Float64(100)}, }} } - counter-- + client.counterForGetMetricDataWithContext-- return &cloudwatch.GetMetricDataOutput{ MetricDataResults: res, NextToken: aws.String(nextToken), @@ -39,7 +39,7 @@ func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, in func TestGetMetricDataExecutorTest(t *testing.T) { executor := &cloudWatchExecutor{} inputs := &cloudwatch.GetMetricDataInput{MetricDataQueries: []*cloudwatch.MetricDataQuery{}} - res, err := executor.executeRequest(context.Background(), &cloudWatchFakeClient{}, inputs) + res, err := executor.executeRequest(context.Background(), &cloudWatchFakeClient{counterForGetMetricDataWithContext: 1}, inputs) require.NoError(t, err) require.Len(t, res, 2) require.Len(t, res[0].MetricDataResults[0].Values, 2) diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index 4d3df038de3..bfe6780a52f 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -28,7 +28,7 @@ func TestQuery_DescribeLogGroups(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } t.Run("Empty log group name prefix", func(t *testing.T) { @@ -155,7 +155,7 @@ func TestQuery_GetLogGroupFields(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } cli = FakeCWLogsClient{ @@ -232,7 +232,7 @@ func TestQuery_StartQuery(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } t.Run("invalid time range", func(t *testing.T) { @@ -357,6 +357,108 @@ func TestQuery_StartQuery(t *testing.T) { }) } +func Test_executeStartQuery(t *testing.T) { + origNewCWLogsClient := NewCWLogsClient + t.Cleanup(func() { + NewCWLogsClient = origNewCWLogsClient + }) + + var cli FakeCWLogsClient + + NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { + return &cli + } + + t.Run("successfully parses information from JSON to StartQueryWithContext", func(t *testing.T) { + cli = FakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12, + "queryString":"fields @message", + "logGroupNames":["some name","another name"] + }`), + }, + }, + }) + + assert.NoError(t, err) + assert.Equal(t, []*cloudwatchlogs.StartQueryInput{ + { + StartTime: pointerInt64(0), + EndTime: pointerInt64(1), + Limit: pointerInt64(12), + QueryString: pointerString("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), + LogGroupNames: []*string{pointerString("some name"), pointerString("another name")}, + }, + }, cli.calls.startQueryWithContext) + }) + + t.Run("cannot parse limit as float", func(t *testing.T) { + cli = FakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12.0 + }`), + }, + }, + }) + + assert.NoError(t, err) + require.Len(t, cli.calls.startQueryWithContext, 1) + assert.Nil(t, cli.calls.startQueryWithContext[0].Limit) + }) + + t.Run("does not populate StartQueryInput.limit when no limit provided", func(t *testing.T) { + cli = FakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery" + }`), + }, + }, + }) + + assert.NoError(t, err) + require.Len(t, cli.calls.startQueryWithContext, 1) + assert.Nil(t, cli.calls.startQueryWithContext[0].Limit) + }) +} + func TestQuery_StopQuery(t *testing.T) { origNewCWLogsClient := NewCWLogsClient t.Cleanup(func() { @@ -366,7 +468,7 @@ func TestQuery_StopQuery(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } cli = FakeCWLogsClient{ @@ -438,7 +540,7 @@ func TestQuery_GetQueryResults(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } const refID = "A" diff --git a/pkg/tsdb/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metrics.go index 6234f013913..cf2779ab8e7 100644 --- a/pkg/tsdb/cloudwatch/metrics.go +++ b/pkg/tsdb/cloudwatch/metrics.go @@ -514,7 +514,7 @@ var dimensionsMap = map[string][]string{ // Known AWS regions. var knownRegions = []string{ "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", - "ap-southeast-2", "ca-central-1", "cn-north-1", "cn-northwest-1", "eu-central-1", "eu-north-1", "eu-south-1", "eu-west-1", + "ap-southeast-2", "ap-southeast-3", "ca-central-1", "cn-north-1", "cn-northwest-1", "eu-central-1", "eu-north-1", "eu-south-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-gov-east-1", "us-gov-west-1", "us-iso-east-1", "us-isob-east-1", "us-west-1", "us-west-2", } diff --git a/pkg/tsdb/cloudwatch/test_utils.go b/pkg/tsdb/cloudwatch/test_utils.go index 8eac803c83a..d8c8ce1f07a 100644 --- a/pkg/tsdb/cloudwatch/test_utils.go +++ b/pkg/tsdb/cloudwatch/test_utils.go @@ -20,32 +20,41 @@ import ( type FakeCWLogsClient struct { cloudwatchlogsiface.CloudWatchLogsAPI + + calls logsQueryCalls + logGroups cloudwatchlogs.DescribeLogGroupsOutput logGroupFields cloudwatchlogs.GetLogGroupFieldsOutput queryResults cloudwatchlogs.GetQueryResultsOutput } -func (m FakeCWLogsClient) GetQueryResultsWithContext(ctx context.Context, input *cloudwatchlogs.GetQueryResultsInput, option ...request.Option) (*cloudwatchlogs.GetQueryResultsOutput, error) { +type logsQueryCalls struct { + startQueryWithContext []*cloudwatchlogs.StartQueryInput +} + +func (m *FakeCWLogsClient) GetQueryResultsWithContext(ctx context.Context, input *cloudwatchlogs.GetQueryResultsInput, option ...request.Option) (*cloudwatchlogs.GetQueryResultsOutput, error) { return &m.queryResults, nil } -func (m FakeCWLogsClient) StartQueryWithContext(ctx context.Context, input *cloudwatchlogs.StartQueryInput, option ...request.Option) (*cloudwatchlogs.StartQueryOutput, error) { +func (m *FakeCWLogsClient) StartQueryWithContext(ctx context.Context, input *cloudwatchlogs.StartQueryInput, option ...request.Option) (*cloudwatchlogs.StartQueryOutput, error) { + m.calls.startQueryWithContext = append(m.calls.startQueryWithContext, input) + return &cloudwatchlogs.StartQueryOutput{ QueryId: aws.String("abcd-efgh-ijkl-mnop"), }, nil } -func (m FakeCWLogsClient) StopQueryWithContext(ctx context.Context, input *cloudwatchlogs.StopQueryInput, option ...request.Option) (*cloudwatchlogs.StopQueryOutput, error) { +func (m *FakeCWLogsClient) StopQueryWithContext(ctx context.Context, input *cloudwatchlogs.StopQueryInput, option ...request.Option) (*cloudwatchlogs.StopQueryOutput, error) { return &cloudwatchlogs.StopQueryOutput{ Success: aws.Bool(true), }, nil } -func (m FakeCWLogsClient) DescribeLogGroupsWithContext(ctx context.Context, input *cloudwatchlogs.DescribeLogGroupsInput, option ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { +func (m *FakeCWLogsClient) DescribeLogGroupsWithContext(ctx context.Context, input *cloudwatchlogs.DescribeLogGroupsInput, option ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { return &m.logGroups, nil } -func (m FakeCWLogsClient) GetLogGroupFieldsWithContext(ctx context.Context, input *cloudwatchlogs.GetLogGroupFieldsInput, option ...request.Option) (*cloudwatchlogs.GetLogGroupFieldsOutput, error) { +func (m *FakeCWLogsClient) GetLogGroupFieldsWithContext(ctx context.Context, input *cloudwatchlogs.GetLogGroupFieldsInput, option ...request.Option) (*cloudwatchlogs.GetLogGroupFieldsOutput, error) { return &m.logGroupFields, nil } @@ -192,3 +201,10 @@ func (s fakeSessionCache) GetSession(c awsds.SessionConfig) (*session.Session, e Config: &aws.Config{}, }, nil } + +func pointerString(s string) *string { + return &s +} +func pointerInt64(i int64) *int64 { + return &i +} diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts index 8070eeb8688..5479778b689 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts @@ -1,11 +1,12 @@ import { dateTime } from '@grafana/data'; import { setBackendSrv } from '@grafana/runtime'; -import { TemplateSrvMock } from '../../../../features/templating/template_srv.mock'; +import { TemplateSrv } from 'app/features/templating/template_srv'; import { initialCustomVariableModelState } from 'app/features/variables/custom/reducer'; import { CustomVariableModel } from 'app/features/variables/types'; import { of } from 'rxjs'; + +import { TemplateSrvMock } from '../../../../features/templating/template_srv.mock'; import { CloudWatchDatasource } from '../datasource'; -import { TemplateSrv } from 'app/features/templating/template_srv'; export function setupMockedDataSource({ data = [], variables }: { data?: any; variables?: any } = {}) { let templateService = new TemplateSrvMock({ @@ -16,6 +17,8 @@ export function setupMockedDataSource({ data = [], variables }: { data?: any; va if (variables) { templateService = new TemplateSrv(); templateService.init(variables); + templateService.getVariables = jest.fn().mockReturnValue(variables); + templateService.getVariableName = (name: string) => name; } const datasource = new CloudWatchDatasource( diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx index def9d666832..268abb355e1 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx @@ -1,12 +1,11 @@ -import React from 'react'; -import { pick } from 'lodash'; - import { ExploreMode, SelectableValue } from '@grafana/data'; import { EditorHeader, InlineSelect } from '@grafana/experimental'; +import { pick } from 'lodash'; +import React from 'react'; import { CloudWatchDatasource } from '../datasource'; -import { CloudWatchQuery, CloudWatchQueryMode } from '../types'; import { useRegions } from '../hooks'; +import { CloudWatchQuery, CloudWatchQueryMode } from '../types'; import MetricsQueryHeader from './MetricsQueryHeader'; interface QueryHeaderProps { @@ -59,7 +58,7 @@ const QueryHeader: React.FC = ({ v.value === region)} + value={region} placeholder="Select region" allowCustomValue onChange={({ value: region }) => region && onRegion({ value: region })} diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index 591e95dcee6..840e90ffbad 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -1,17 +1,17 @@ -import { lastValueFrom, of } from 'rxjs'; -import { setDataSourceSrv } from '@grafana/runtime'; import { ArrayVector, DataFrame, dataFrameToJSON, dateTime, Field, MutableDataFrame } from '@grafana/data'; - +import { setDataSourceSrv } from '@grafana/runtime'; +import { lastValueFrom, of } from 'rxjs'; import { toArray } from 'rxjs/operators'; -import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType, CloudWatchLogsQueryStatus } from './types'; + import { - setupMockedDataSource, - namespaceVariable, - metricVariable, labelsVariable, limitVariable, + metricVariable, + namespaceVariable, + setupMockedDataSource, } from './__mocks__/CloudWatchDataSource'; import { CloudWatchDatasource } from './datasource'; +import { CloudWatchLogsQueryStatus, CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from './types'; describe('datasource', () => { describe('query', () => { @@ -91,6 +91,57 @@ describe('datasource', () => { }, ]); }); + + describe('debouncedCustomAlert', () => { + const debouncedAlert = jest.fn(); + beforeEach(() => { + const { datasource } = setupMockedDataSource({ + variables: [ + { ...namespaceVariable, multi: true }, + { ...metricVariable, multi: true }, + ], + }); + datasource.debouncedCustomAlert = debouncedAlert; + datasource.performTimeSeriesQuery = jest.fn().mockResolvedValue([]); + datasource.query({ + targets: [ + { + queryMode: 'Metrics', + id: '', + region: 'us-east-2', + namespace: namespaceVariable.id, + metricName: metricVariable.id, + period: '', + alias: '', + dimensions: {}, + matchExact: true, + statistic: '', + refId: '', + expression: 'x * 2', + metricQueryType: MetricQueryType.Search, + metricEditorMode: MetricEditorMode.Code, + }, + ], + } as any); + }); + it('should show debounced alert for namespace and metric name', async () => { + expect(debouncedAlert).toHaveBeenCalledWith( + 'CloudWatch templating error', + 'Multi template variables are not supported for namespace' + ); + expect(debouncedAlert).toHaveBeenCalledWith( + 'CloudWatch templating error', + 'Multi template variables are not supported for metric name' + ); + }); + + it('should not show debounced alert for region', async () => { + expect(debouncedAlert).not.toHaveBeenCalledWith( + 'CloudWatch templating error', + 'Multi template variables are not supported for region' + ); + }); + }); }); describe('filterMetricQuery', () => { diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 3567dc58e9f..32cb083bf7d 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -1,9 +1,3 @@ -import React from 'react'; -import { cloneDeep, find, findLast, isEmpty, isString, set } from 'lodash'; -import { from, lastValueFrom, merge, Observable, of, throwError, zip } from 'rxjs'; -import { catchError, concatMap, finalize, map, mergeMap, repeat, scan, share, takeWhile, tap } from 'rxjs/operators'; -import { DataSourceWithBackend, FetchError, getBackendSrv, toDataQueryResponse } from '@grafana/runtime'; -import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; import { DataFrame, DataQueryError, @@ -22,45 +16,51 @@ import { TimeRange, toLegacyResponseData, } from '@grafana/data'; - +import { DataSourceWithBackend, FetchError, getBackendSrv, toDataQueryResponse } from '@grafana/runtime'; +import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse'; +import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; -import { AppNotificationTimeout } from 'app/types'; -import { store } from 'app/store/store'; -import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { VariableWithMultiSupport } from 'app/features/variables/types'; +import { store } from 'app/store/store'; +import { AppNotificationTimeout } from 'app/types'; +import { cloneDeep, find, findLast, isEmpty, isString, set } from 'lodash'; +import React from 'react'; +import { from, lastValueFrom, merge, Observable, of, throwError, zip } from 'rxjs'; +import { catchError, concatMap, finalize, map, mergeMap, repeat, scan, share, takeWhile, tap } from 'rxjs/operators'; + +import { SQLCompletionItemProvider } from './cloudwatch-sql/completion/CompletionItemProvider'; import { ThrottlingErrorMessage } from './components/ThrottlingErrorMessage'; +import { CloudWatchLanguageProvider } from './language_provider'; import memoizedDebounce from './memoizedDebounce'; +import { MetricMathCompletionItemProvider } from './metric-math/completion/CompletionItemProvider'; import { - MetricEditorMode, CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchLogsQueryStatus, + CloudWatchLogsRequest, CloudWatchMetricsQuery, CloudWatchQuery, DescribeLogGroupsRequest, + Dimensions, GetLogEventsRequest, GetLogGroupFieldsRequest, GetLogGroupFieldsResponse, isCloudWatchLogsQuery, LogAction, - MetricQueryType, + MetricEditorMode, + MetricFindSuggestData, MetricQuery, + MetricQueryType, MetricRequest, StartQueryRequest, TSDBResponse, - Dimensions, - MetricFindSuggestData, - CloudWatchLogsRequest, } from './types'; -import { CloudWatchLanguageProvider } from './language_provider'; -import { VariableWithMultiSupport } from 'app/features/variables/types'; -import { increasingInterval } from './utils/rxjs/increasingInterval'; -import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse'; import { addDataLinksToLogsResponse } from './utils/datalinks'; import { runWithRetry } from './utils/logsRetry'; -import { SQLCompletionItemProvider } from './cloudwatch-sql/completion/CompletionItemProvider'; -import { MetricMathCompletionItemProvider } from './metric-math/completion/CompletionItemProvider'; +import { increasingInterval } from './utils/rxjs/increasingInterval'; const DS_QUERY_ENDPOINT = '/api/ds/query'; @@ -267,7 +267,7 @@ export class CloudWatchDatasource const validMetricsQueries = metricQueries .filter(this.filterMetricQuery) .map((item: CloudWatchMetricsQuery): MetricQuery => { - item.region = this.replace(this.getActualRegion(item.region), options.scopedVars, true, 'region'); + item.region = this.templateSrv.replace(this.getActualRegion(item.region), options.scopedVars); item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace'); item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name'); item.dimensions = this.convertDimensionFormat(item.dimensions ?? {}, options.scopedVars); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts index 080cee7d06a..d7031081f88 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts @@ -1,11 +1,12 @@ -import AzureMonitorDatasource from '../datasource'; -import AzureLogAnalyticsDatasource from './azure_log_analytics_datasource'; -import FakeSchemaData from './__mocks__/schema'; -import { TemplateSrv } from 'app/features/templating/template_srv'; -import { AzureMonitorQuery, AzureQueryType, DatasourceValidationResult } from '../types'; import { toUtc } from '@grafana/data'; +import { TemplateSrv } from 'app/features/templating/template_srv'; + import createMockQuery from '../__mocks__/query'; import { singleVariable } from '../__mocks__/variables'; +import AzureMonitorDatasource from '../datasource'; +import { AzureMonitorQuery, AzureQueryType, DatasourceValidationResult } from '../types'; +import FakeSchemaData from './__mocks__/schema'; +import AzureLogAnalyticsDatasource from './azure_log_analytics_datasource'; const templateSrv = new TemplateSrv(); @@ -273,7 +274,7 @@ describe('AzureLogAnalyticsDatasource', () => { laDatasource = new AzureLogAnalyticsDatasource(ctx.instanceSettings); }); - it('should run complete queries', () => { + it('should run queries with a resource', () => { const query: AzureMonitorQuery = { refId: 'A', azureLogAnalytics: { @@ -285,6 +286,18 @@ describe('AzureLogAnalyticsDatasource', () => { expect(laDatasource.filterQuery(query)).toBeTruthy(); }); + it('should run queries with a workspace', () => { + const query: AzureMonitorQuery = { + refId: 'A', + azureLogAnalytics: { + query: 'perf | take 100', + workspace: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67', + }, + }; + + expect(laDatasource.filterQuery(query)).toBeTruthy(); + }); + it('should not run empty queries', () => { const query: AzureMonitorQuery = { refId: 'A', @@ -317,7 +330,7 @@ describe('AzureLogAnalyticsDatasource', () => { expect(laDatasource.filterQuery(query)).toBeFalsy(); }); - it('should not run queries missing a resource', () => { + it('should not run queries missing a resource and a missing workspace', () => { const query: AzureMonitorQuery = { refId: 'A', azureLogAnalytics: { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts index 1bedebfc2b7..b4458f8d640 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts @@ -1,26 +1,27 @@ -import { map } from 'lodash'; -import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder'; -import ResponseParser, { transformMetadataToKustoSchema } from './response_parser'; -import { - AzureMonitorQuery, - AzureDataSourceJsonData, - AzureLogsVariable, - AzureQueryType, - DatasourceValidationResult, -} from '../types'; import { DataQueryRequest, DataQueryResponse, - ScopedVars, DataSourceInstanceSettings, DataSourceRef, + ScopedVars, } from '@grafana/data'; -import { getTemplateSrv, DataSourceWithBackend } from '@grafana/runtime'; -import { Observable, from } from 'rxjs'; +import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime'; +import { map } from 'lodash'; +import { from, Observable } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; -import { getAuthType, getAzureCloud, getAzurePortalUrl } from '../credentials'; + import { isGUIDish } from '../components/ResourcePicker/utils'; +import { getAuthType, getAzureCloud, getAzurePortalUrl } from '../credentials'; +import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder'; +import { + AzureDataSourceJsonData, + AzureLogsVariable, + AzureMonitorQuery, + AzureQueryType, + DatasourceValidationResult, +} from '../types'; import { interpolateVariable, routeNames } from '../utils/common'; +import ResponseParser, { transformMetadataToKustoSchema } from './response_parser'; interface AdhocQuery { datasource: DataSourceRef; @@ -60,7 +61,11 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend< } filterQuery(item: AzureMonitorQuery): boolean { - return item.hide !== true && !!item.azureLogAnalytics?.query && !!item.azureLogAnalytics.resource; + return ( + item.hide !== true && + !!item.azureLogAnalytics?.query && + (!!item.azureLogAnalytics.resource || !!item.azureLogAnalytics.workspace) + ); } async getSubscriptions(): Promise> { diff --git a/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx b/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx new file mode 100644 index 00000000000..77d85ac8526 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx @@ -0,0 +1,154 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; +import { DataQueryRequest, DataSourceInstanceSettings, dateTime, PluginType } from '@grafana/data'; +import { of } from 'rxjs'; +import { JaegerDatasource, JaegerJsonData } from '../datasource'; +import { JaegerQuery } from '../types'; +import React from 'react'; +import SearchForm from './SearchForm'; +import { testResponse } from '../testResponse'; +import userEvent from '@testing-library/user-event'; + +describe('SearchForm', () => { + it('should call the `onChange` function on click of the Input', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => promise); + const query = { + ...defaultQuery, + targets: [ + { + query: 'a/b', + refId: '1', + }, + ], + refId: '121314', + }; + const ds = { + async metadataRequest(url: string, params?: Record): Promise { + if (url === '/api/services') { + return Promise.resolve(['jaeger-query', 'service2', 'service3']); + } + }, + } as JaegerDatasource; + setupFetchMock({ data: [testResponse] }); + + render(); + + const asyncServiceSelect = await waitFor(() => screen.getByRole('combobox', { name: 'select-service-name' })); + expect(asyncServiceSelect).toBeInTheDocument(); + + userEvent.click(asyncServiceSelect); + + const jaegerService = await screen.findByText('jaeger-query'); + expect(jaegerService).toBeInTheDocument(); + }); + + it('should be able to select operation name if query.service exists', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => promise); + const query2 = { + ...defaultQuery, + targets: [ + { + query: 'a/b', + refId: '1', + }, + ], + refId: '121314', + service: 'jaeger-query', + }; + setupFetchMock({ data: [testResponse] }); + + render(); + + const asyncOperationSelect2 = await waitFor(() => screen.getByRole('combobox', { name: 'select-operation-name' })); + expect(asyncOperationSelect2).toBeInTheDocument(); + }); +}); + +describe('SearchForm', () => { + it('should show loader if there is a delay fetching options', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => { + setTimeout(() => { + return promise; + }, 3000); + }); + const query = { + ...defaultQuery, + targets: [ + { + query: 'a/b', + refId: '1', + }, + ], + refId: '121314', + service: 'jaeger-query', + }; + const ds = new JaegerDatasource(defaultSettings); + setupFetchMock({ data: [testResponse] }); + + render(); + + const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' }); + userEvent.click(asyncServiceSelect); + const loader = screen.getByText('Loading options...'); + + expect(loader).toBeInTheDocument(); + await act(() => promise); + }); +}); + +function setupFetchMock(response: any, mock?: any) { + const defaultMock = () => mock ?? of(createFetchResponse(response)); + + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + fetchMock.mockImplementation(defaultMock); + return fetchMock; +} + +const defaultSettings: DataSourceInstanceSettings = { + id: 0, + uid: '0', + type: 'tracing', + name: 'jaeger', + url: 'http://grafana.com', + access: 'proxy', + meta: { + id: 'jaeger', + name: 'jaeger', + type: PluginType.datasource, + info: {} as any, + module: '', + baseUrl: '', + }, + jsonData: { + nodeGraph: { + enabled: true, + }, + }, +}; + +const defaultQuery: DataQueryRequest = { + requestId: '1', + dashboardId: 0, + interval: '0', + intervalMs: 10, + panelId: 0, + scopedVars: {}, + range: { + from: dateTime().subtract(1, 'h'), + to: dateTime(), + raw: { from: '1h', to: 'now' }, + }, + timezone: 'browser', + app: 'explore', + startTime: 0, + targets: [ + { + query: '12345', + refId: '1', + }, + ], +}; diff --git a/public/app/plugins/datasource/jaeger/components/SearchForm.tsx b/public/app/plugins/datasource/jaeger/components/SearchForm.tsx index 1c4e6de235b..c362314bbed 100644 --- a/public/app/plugins/datasource/jaeger/components/SearchForm.tsx +++ b/public/app/plugins/datasource/jaeger/components/SearchForm.tsx @@ -1,11 +1,14 @@ import { css } from '@emotion/css'; import { SelectableValue } from '@grafana/data'; -import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui'; -import React, { useEffect, useState } from 'react'; +import { AsyncSelect, InlineField, InlineFieldRow, Input } from '@grafana/ui'; +import React, { useCallback, useEffect, useState } from 'react'; import { JaegerDatasource } from '../datasource'; import { JaegerQuery } from '../types'; import { transformToLogfmt } from '../util'; import { AdvancedOptions } from './AdvancedOptions'; +import { dispatch } from 'app/store/store'; +import { notifyApp } from 'app/core/actions'; +import { createErrorNotification } from 'app/core/copy/appNotification'; type Props = { datasource: JaegerDatasource; @@ -22,69 +25,110 @@ const allOperationsOption: SelectableValue = { export function SearchForm({ datasource, query, onChange }: Props) { const [serviceOptions, setServiceOptions] = useState>>(); const [operationOptions, setOperationOptions] = useState>>(); + const [isLoading, setIsLoading] = useState<{ + services: boolean; + operations: boolean; + }>({ + services: false, + operations: false, + }); + + const loadServices = useCallback( + async (url: string, loaderOfType: string): Promise>> => { + setIsLoading((prevValue) => ({ ...prevValue, [loaderOfType]: true })); + + try { + const values: string[] | null = await datasource.metadataRequest(url); + if (!values) { + return [{ label: `No ${loaderOfType} found`, value: `No ${loaderOfType} found` }]; + } + + const serviceOptions: SelectableValue[] = values.sort().map((service) => ({ + label: service, + value: service, + })); + return serviceOptions; + } catch (error) { + dispatch(notifyApp(createErrorNotification('Error', error))); + return []; + } finally { + setIsLoading((prevValue) => ({ ...prevValue, [loaderOfType]: false })); + } + }, + [datasource] + ); useEffect(() => { const getServices = async () => { - const services = await loadServices({ - dataSource: datasource, - url: '/api/services', - notFoundLabel: 'No service found', - }); + const services = await loadServices('/api/services', 'services'); setServiceOptions(services); }; getServices(); - }, [datasource]); + }, [datasource, loadServices]); useEffect(() => { const getOperations = async () => { - const operations = await loadServices({ - dataSource: datasource, - url: `/api/services/${encodeURIComponent(query.service!)}/operations`, - notFoundLabel: 'No operation found', - }); + const operations = await loadServices( + `/api/services/${encodeURIComponent(query.service!)}/operations`, + 'operations' + ); setOperationOptions([allOperationsOption, ...operations]); }; if (query.service) { getOperations(); } - }, [datasource, query.service]); + }, [datasource, query.service, loadServices]); return (
- + loadServices(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations') + } + onOpenMenu={() => + loadServices(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations') + } + isLoading={isLoading.operations} value={operationOptions?.find((v) => v.value === query.operation) || null} onChange={(v) => onChange({ ...query, - operation: v.value!, + operation: v?.value! || undefined, }) } menuPlacement="bottom" isClearable + defaultOptions + aria-label={'select-operation-name'} /> @@ -108,19 +152,4 @@ export function SearchForm({ datasource, query, onChange }: Props) { ); } -type Options = { dataSource: JaegerDatasource; url: string; notFoundLabel: string }; - -const loadServices = async ({ dataSource, url, notFoundLabel }: Options): Promise>> => { - const services: string[] | null = await dataSource.metadataRequest(url); - - if (!services) { - return [{ label: notFoundLabel, value: notFoundLabel }]; - } - - const serviceOptions: SelectableValue[] = services.sort().map((service) => ({ - label: service, - value: service, - })); - - return serviceOptions; -}; +export default SearchForm; diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index eb9437b53e9..31a0cfdc71b 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -138,7 +138,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { // this expands the distr: 2 scale so that the indicies of each data[0] land at the proper justified positions const xRange: Scale.Range = (u, min, max) => { min = 0; - max = u.data[0].length - 1; + max = Math.max(1, u.data[0].length - 1); let pctOffset = 0; @@ -148,13 +148,17 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { }); // expand scale range by equal amounts on both ends - let rn = max - min; // TODO: clamp to 1? + let rn = max - min; - let upScale = 1 / (1 - pctOffset * 2); - let offset = (upScale * rn - rn) / 2; + if (pctOffset === 0.5) { + min -= rn; + } else { + let upScale = 1 / (1 - pctOffset * 2); + let offset = (upScale * rn - rn) / 2; - min -= offset; - max += offset; + min -= offset; + max += offset; + } return [min, max]; }; diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts index bc5b7a7aae0..37c19b9423e 100644 --- a/public/app/plugins/panel/graph/data_processor.ts +++ b/public/app/plugins/panel/graph/data_processor.ts @@ -12,6 +12,7 @@ import { } from '@grafana/data'; import TimeSeries from 'app/core/time_series2'; import config from 'app/core/config'; +import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold'; type Options = { dataList: DataFrame[]; @@ -30,13 +31,15 @@ export class DataProcessor { } for (let i = 0; i < dataList.length; i++) { - const series = dataList[i]; + let series = dataList[i]; const { timeField } = getTimeField(series); if (!timeField) { continue; } + series = applyNullInsertThreshold(series, timeField.name); + for (let j = 0; j < series.fields.length; j++) { const field = series.fields[j]; diff --git a/public/app/plugins/panel/histogram/Histogram.tsx b/public/app/plugins/panel/histogram/Histogram.tsx index a4b21980c2f..29115ecff96 100644 --- a/public/app/plugins/panel/histogram/Histogram.tsx +++ b/public/app/plugins/panel/histogram/Histogram.tsx @@ -15,7 +15,15 @@ import { getFieldSeriesColor, GrafanaTheme2, } from '@grafana/data'; -import { Themeable2, UPlotConfigBuilder, UPlotChart, VizLayout, PlotLegend } from '@grafana/ui'; +import { + Themeable2, + UPlotConfigBuilder, + UPlotChart, + VizLayout, + PlotLegend, + measureText, + UPLOT_AXIS_FONT_SIZE, +} from '@grafana/ui'; import { histogramBucketSizes, @@ -119,7 +127,20 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { placement: AxisPlacement.Bottom, incrs: histogramBucketSizes, splits: xSplits, - values: (u: uPlot, vals: any[]) => vals.map(xAxisFormatter), + values: (u: uPlot, splits: any[]) => { + const tickLabels = splits.map(xAxisFormatter); + + const maxWidth = tickLabels.reduce( + (curMax, label) => Math.max(measureText(label, UPLOT_AXIS_FONT_SIZE).width, curMax), + 0 + ); + + const labelSpacing = 10; + const maxCount = u.bbox.width / ((maxWidth + labelSpacing) * devicePixelRatio); + const keepMod = Math.ceil(tickLabels.length / maxCount); + + return tickLabels.map((label, i) => (i % keepMod === 0 ? label : null)); + }, //incrs: () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((mult) => mult * bucketSize), //splits: config.xSplits, //values: config.xValues, diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts index 1b5a7ff3036..5db103c4e61 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/plugins/panel/state-timeline/utils.ts @@ -553,16 +553,18 @@ export function findNextStateIndex(field: Field, datapointIdx: number) { return null; } + const startValue = field.values.get(datapointIdx); + while (end === undefined) { if (rightPointer >= field.values.length) { return null; } const rightValue = field.values.get(rightPointer); - if (rightValue !== undefined) { - end = rightPointer; - } else { + if (rightValue === undefined || rightValue === startValue) { rightPointer++; + } else { + end = rightPointer; } }