diff --git a/.drone.yml b/.drone.yml index aec33809cfa..c32491de2c0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -3489,7 +3489,7 @@ steps: - name: slack-notify-failure image: plugins/slack settings: - channel: grafana-backend + channel: grafana-backend-ops template: "Nightly docker image scan job for {{repo.name}} failed: {{build.link}}" webhook: from_secret: slack_webhook_backend @@ -3529,6 +3529,6 @@ get: --- kind: signature -hmac: 4f23649a1678c66fb96af929675bcf569cca3b208d425eace86150d981ee9fbb +hmac: 0ab7831c1f9acfcc20fbd011bd0e04f88f85f77d9997ab7a5092592bbcd8ae34 ... diff --git a/CHANGELOG.md b/CHANGELOG.md index 9533c8acc16..673efaf4acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,11 @@ - # 8.1.1 (2021-08-09) ### Bug fixes -* **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) -* **Reporting:** Fix timezone parsing for scheduler (enterprise) +- **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) +- **Reporting:** Fix timezone parsing for scheduler (enterprise) diff --git a/Makefile b/Makefile index dbfe41812e7..6f4e32ae1f0 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ build-server: ## Build Grafana server. $(GO) run build.go build-server build-cli: ## Build Grafana CLI application. - @echo "build in CI environment" + @echo "build grafana-cli" $(GO) run build.go build-cli build-js: ## Build frontend assets. diff --git a/cue/data/gen.cue b/cue/data/gen.cue index 60de8d603fa..6279bf407b6 100644 --- a/cue/data/gen.cue +++ b/cue/data/gen.cue @@ -113,6 +113,13 @@ Family: scuemata.#Family & { steps: [...#Threshold] } @cuetsy(targetType="interface") + // TODO docs + // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it + #Transformation: { + id: string + options: {...} + } + // Schema for panel targets is specified by datasource // plugins. We use a placeholder definition, which the Go // schema loader either left open/as-is with the Base @@ -197,6 +204,8 @@ Family: scuemata.#Family & { // TODO docs timeRegions?: [...] + transformations: [...#Transformation] + // TODO docs // TODO tighter constraint interval?: string @@ -209,8 +218,10 @@ Family: scuemata.#Family & { // TODO tighter constraint timeShift?: string - // The values depend on panel type - options: {...} + // The allowable options are specified by the panel plugin's + // schema. + // FIXME same conundrum as with the closed validation for fieldConfig. + options: {} fieldConfig: { defaults: { @@ -282,7 +293,7 @@ Family: scuemata.#Family & { // Can always exist. Valid fields within this are // defined by the panel plugin - that's the // PanelFieldConfig that comes from the plugin. - custom?: {...} + custom?: {} } overrides: [...{ matcher: { diff --git a/cue/ui/gen.cue b/cue/ui/gen.cue index ebd849e93b9..7e1037003e7 100644 --- a/cue/ui/gen.cue +++ b/cue/ui/gen.cue @@ -1,21 +1,22 @@ package grafanaschema +// FIXME can't write enums as structs, must use disjunctions TableCellDisplayMode: { - Auto: "auto", - ColorText: "color-text", - ColorBackground: "color-background", - GradientGauge: "gradient-gauge", - LcdGauge: "lcd-gauge", - JSONView: "json-view", - BasicGauge: "basic", - Image: "image", + Auto: "auto", + ColorText: "color-text", + ColorBackground: "color-background", + GradientGauge: "gradient-gauge", + LcdGauge: "lcd-gauge", + JSONView: "json-view", + BasicGauge: "basic", + Image: "image", } @cuetsy(targetType="enum") TableFieldOptions: { - width?: number - align: FieldTextAlignment | *"auto" - displayMode: TableCellDisplayMode | *"auto" - hidden?: bool // ?? default is missing or false ?? + width?: number + align: FieldTextAlignment | *"auto" + displayMode: TableCellDisplayMode | *"auto" + hidden?: bool // ?? default is missing or false ?? } @cuetsy(targetType="interface") TableSortByFieldState: { @@ -31,6 +32,11 @@ DrawStyle: "line" | "bars" | "points" @c LineInterpolation: "linear" | "smooth" | "stepBefore" | "stepAfter" @cuetsy(targetType="enum") ScaleDistribution: "linear" | "log" @cuetsy(targetType="enum") GraphGradientMode: "none" | "opacity" | "hue" | "scheme" @cuetsy(targetType="enum") +StackingMode: "none" | "normal" | "percent" @cuetsy(targetType="enum") +BarValueVisibility: "auto" | "never" | "always" @cuetsy(targetType="enum") +BarAlignment: -1 | 0 | 1 @cuetsy(targetType="enum",memberNames="Before|Center|After") +ScaleOrientation: 0 | 1 @cuetsy(targetType="enum",memberNames="Horizontal|Vertical") +ScaleDirection: 1 | 1 | -1 | -1 @cuetsy(targetType="enum",memberNames="Up|Right|Down|Left") LineStyle: { fill?: "solid" | "dash" | "dot" | "square" dash?: [...number] @@ -42,6 +48,11 @@ LineConfig: { lineStyle?: LineStyle spanNulls?: bool | number } @cuetsy(targetType="interface") +BarConfig: { + barAlignment?: BarAlignment + barWidthFactor?: number + barMaxWidth?: number +} @cuetsy(targetType="interface") FillConfig: { fillColor?: string fillOpacity?: number @@ -70,6 +81,20 @@ HideSeriesConfig: { legend: bool viz: bool } @cuetsy(targetType="interface") +StackingConfig: { + mode?: StackingMode + group?: string +} @cuetsy(targetType="interface") +StackableFieldConfig: { + stacking?: StackingConfig +} @cuetsy(targetType="interface") +HideableFieldConfig: { + hideFrom?: HideSeriesConfig +} @cuetsy(targetType="interface") +GraphTresholdsStyleMode: "off" | "line" | "area" | "line+area" | "series" @cuetsy(targetType="enum",memberNames="Off|Line|Area|LineAndArea|Series") +GraphThresholdsStyleConfig: { + mode: GraphTresholdsStyleMode +} @cuetsy(targetType="interface") LegendPlacement: "bottom" | "right" @cuetsy(targetType="type") LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(targetType="enum") TableFieldOptions: { @@ -78,10 +103,17 @@ TableFieldOptions: { displayMode: TableCellDisplayMode | *"auto" hidden?: bool } @cuetsy(targetType="interface") -GraphFieldConfig: LineConfig & FillConfig & PointsConfig & AxisConfig & { - drawStyle?: DrawStyle - gradientMode?: GraphGradientMode - hideFrom?: HideSeriesConfig +GraphFieldConfig: { + LineConfig + FillConfig + PointsConfig + AxisConfig + BarConfig + StackableFieldConfig + HideableFieldConfig + drawStyle?: DrawStyle + gradientMode?: GraphGradientMode + thresholdsStyle?: GraphThresholdsStyleConfig } @cuetsy(targetType="interface") VizLegendOptions: { displayMode: LegendDisplayMode @@ -93,3 +125,54 @@ VizLegendOptions: { VizTooltipOptions: { mode: TooltipDisplayMode } @cuetsy(targetType="interface") +// TODO copy back to appropriate place +SingleStatBaseOptions: { + OptionsWithTextFormatting + reduceOptions: ReduceDataOptions + orientation: VizOrientation +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +ReduceDataOptions: { + // If true show each row value + values?: bool + // if showing all values limit + limit?: number + // When !values, pick one value for the whole field + calcs: [...string] + // Which fields to show. By default this is only numeric fields + fields?: string +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +VizOrientation: "auto" | "vertical" | "horizontal" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +OptionsWithTooltip: { + // FIXME this field is non-optional in the corresponding TS type + tooltip?: VizTooltipOptions +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +OptionsWithLegend: { + // FIXME this field is non-optional in the corresponding TS type + legend?: VizLegendOptions +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +OptionsWithTextFormatting: { + text?: VizTextDisplayOptions +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +VizTextDisplayOptions: { + // Explicit title text size + titleSize?: number + // Explicit value text size + valueSize?: number +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +BigValueColorMode: "value" | "background" | "none" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +BigValueGraphMode: "none" | "line" | "area" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +BigValueJustifyMode: "auto" | "center" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +// TODO does cuetsy handle underscores the expected way? +BigValueTextMode: "auto" | "value" | "value_and_name" | "name" | "none" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +BarGaugeDisplayMode: "basic" | "lcd" | "gradient" @cuetsy(targetType="enum") \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json index 8c90171d957..d65e63b22a9 100644 --- a/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json +++ b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json @@ -354,7 +354,7 @@ "orientation": "auto", "showValue": "auto", "text": { - "size": 10, + "titleSize": 10, "valueSize": 25 }, "tooltip": { diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json b/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json index 65f44b9ba88..9afd53a1f27 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json @@ -19,12 +19,10 @@ ] }, "editable": true, - "gnetId": null, "graphTooltip": 0, "links": [], "panels": [ { - "datasource": null, "description": "", "fieldConfig": { "defaults": { @@ -66,8 +64,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -112,7 +109,6 @@ "type": "timeseries" }, { - "datasource": null, "description": "", "fieldConfig": { "defaults": { @@ -154,8 +150,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -200,7 +195,6 @@ "type": "timeseries" }, { - "datasource": null, "fieldConfig": { "defaults": { "color": { @@ -241,8 +235,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -328,8 +321,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -373,7 +365,6 @@ "type": "timeseries" }, { - "datasource": null, "fieldConfig": { "defaults": { "color": { @@ -414,8 +405,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -461,7 +451,6 @@ "startValue": 1 } ], - "timeFrom": null, "title": "Color bars by discrete thresholds", "type": "timeseries" }, @@ -507,8 +496,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -597,8 +585,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -687,8 +674,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -736,7 +722,6 @@ "type": "timeseries" }, { - "datasource": null, "fieldConfig": { "defaults": { "color": { @@ -777,8 +762,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -859,4 +843,4 @@ "title": "Panel Tests - Graph NG - By value color schemes", "uid": "aBXrJ0R7z", "version": 11 -} \ No newline at end of file +} diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json b/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json index 8e322fbf316..e6472aa80d4 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json @@ -8,6 +8,12 @@ "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, "type": "dashboard" } ] @@ -42,7 +48,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -149,7 +154,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -265,7 +269,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -400,7 +403,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -507,7 +509,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -623,7 +624,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -758,7 +758,6 @@ "fillOpacity": 10, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -902,7 +901,6 @@ "fillOpacity": 10, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -1045,7 +1043,6 @@ "fillOpacity": 10, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -1232,5 +1229,5 @@ "timezone": "", "title": "Panel Tests - Graph NG - Gaps and Connected", "uid": "8mmCAF1Mz", - "version": 12 + "version": 2 } diff --git a/devenv/dev-dashboards/panel-histogram/histogram_tests.json b/devenv/dev-dashboards/panel-histogram/histogram_tests.json index 370432cce46..cf5adbcf6e0 100644 --- a/devenv/dev-dashboards/panel-histogram/histogram_tests.json +++ b/devenv/dev-dashboards/panel-histogram/histogram_tests.json @@ -27,7 +27,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, @@ -86,7 +86,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, @@ -144,7 +144,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, @@ -215,7 +215,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, diff --git a/devenv/dev-dashboards/panel-timeline/timeline-demo.json b/devenv/dev-dashboards/panel-timeline/timeline-demo.json index 0d6523350ad..6830dc069ce 100644 --- a/devenv/dev-dashboards/panel-timeline/timeline-demo.json +++ b/devenv/dev-dashboards/panel-timeline/timeline-demo.json @@ -8,6 +8,12 @@ "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, "type": "dashboard" } ] @@ -79,9 +85,12 @@ "displayMode": "list", "placement": "bottom" }, - "mode": "changes", + "mergeValues": true, "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -168,9 +177,17 @@ "options": { "alignValue": "center", "colWidth": 1, + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "mergeValues": true, "mode": "changes", "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "targets": [ { @@ -261,9 +278,17 @@ "options": { "alignValue": "center", "colWidth": 1, + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "mergeValues": true, "mode": "changes", "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "targets": [ { @@ -339,9 +364,11 @@ "displayMode": "list", "placement": "bottom" }, - "mode": "samples", "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -400,5 +427,5 @@ "timezone": "utc", "title": "Timeline Demo", "uid": "mIJjFy8Kz", - "version": 13 + "version": 3 } diff --git a/devenv/dev-dashboards/panel-timeline/timeline-modes.json b/devenv/dev-dashboards/panel-timeline/timeline-modes.json index cd613c34ccd..d76c7db9609 100644 --- a/devenv/dev-dashboards/panel-timeline/timeline-modes.json +++ b/devenv/dev-dashboards/panel-timeline/timeline-modes.json @@ -8,6 +8,12 @@ "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, "type": "dashboard" } ] @@ -60,7 +66,10 @@ }, "mergeValues": true, "rowHeight": 0.9, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -233,7 +242,10 @@ }, "mergeValues": true, "rowHeight": 0.9, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -305,7 +317,10 @@ "placement": "bottom" }, "rowHeight": 0.9, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -360,5 +375,5 @@ "timezone": "utc", "title": "Timeline Modes", "uid": "mIJjFy8Gz", - "version": 12 + "version": 13 } diff --git a/docs/sources/datasources/alertmanager.md b/docs/sources/datasources/alertmanager.md index 4c181b91057..76ac065fff9 100644 --- a/docs/sources/datasources/alertmanager.md +++ b/docs/sources/datasources/alertmanager.md @@ -10,7 +10,7 @@ weight = 150 Grafana includes built-in support for Prometheus Alertmanager. It is presently in alpha and not accessible unless [alpha plugins are enabled in Grafana settings](https://grafana.com/docs/grafana/latest/administration/configuration/#enable_alpha). Once you add it as a data source, you can use the [Grafana alerting UI](https://grafana.com/docs/grafana/latest/alerting/) to manage silences, contact points as well as notification policies. A drop down option in these pages allows you to switch between Grafana and any configured Alertmanager data sources . -> **Note:** Currently, the [Cortex implementation of Prometheus Alertmanager](https://cortexmetrics.io/docs/proposals/scalable-alertmanager/) is required to edit rules. +> **Note:** Currently, the [Cortex implementation of Prometheus Alertmanager](https://cortexmetrics.io/docs/proposals/scalable-alertmanager/) is required to edit rules. ## Provision the Alertmanager data source diff --git a/docs/sources/http_api/access_control.md b/docs/sources/http_api/access_control.md index 3937fb72294..a69718203a0 100644 --- a/docs/sources/http_api/access_control.md +++ b/docs/sources/http_api/access_control.md @@ -26,7 +26,6 @@ Returns an indicator to check if fine-grained access control is enabled or not. | -------------------- | ---------------------- | | status:accesscontrol | services:accesscontrol | - #### Example request ```http @@ -256,7 +255,6 @@ Content-Type: application/json; charset=UTF-8 #### Status codes - | Code | Description | | ---- | ---------------------------------------------------------------------------------- | | 200 | Role is updated. | @@ -279,7 +277,6 @@ For example, if a user does not have required permissions for creating users, th | ----------- | -------------------- | | roles:write | permissions:delegate | - #### Example request ```http @@ -377,7 +374,6 @@ For example, if a user does not have required permissions for creating users, th | ------------ | -------------------- | | roles:delete | permissions:delegate | - #### Example request ```http diff --git a/docs/sources/release-notes/release-notes-8-1-1.md b/docs/sources/release-notes/release-notes-8-1-1.md index b5e51b3cd23..fb62caa0064 100644 --- a/docs/sources/release-notes/release-notes-8-1-1.md +++ b/docs/sources/release-notes/release-notes-8-1-1.md @@ -10,5 +10,4 @@ list = false ### Bug fixes -* **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) - +- **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) diff --git a/package.json b/package.json index 1e8776c1167..e572c77d8b2 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "dangerously-set-html-content": "1.0.6", "debounce-promise": "3.1.2", "eventemitter3": "4.0.0", + "fast-deep-equal": "^3.1.3", "fast-json-patch": "2.2.1", "fast-text-encoding": "^1.0.0", "file-saver": "2.0.2", diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index e28bd2455da..2fef6fd2085 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -48,6 +48,7 @@ export interface FeatureToggles { ngalert: boolean; trimDefaults: boolean; accesscontrol: boolean; + tempoServiceGraph: boolean; } /** diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index d22c25ca06d..a99217651a6 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -133,7 +133,7 @@ export const Pages = { Explore: { url: '/explore', General: { - container: 'Explore', + container: 'data-testid Explore', graph: 'Explore Graph', table: 'Explore Table', scrollBar: () => '.scrollbar-view', diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 014fccb77ea..b2d48fb97ad 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -63,6 +63,7 @@ export class GrafanaBootConfig implements GrafanaConfig { ngalert: false, accesscontrol: false, trimDefaults: false, + tempoServiceGraph: false, }; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; diff --git a/packages/grafana-runtime/src/services/backendSrv.ts b/packages/grafana-runtime/src/services/backendSrv.ts index eb9249daf8a..8f39f2551c1 100644 --- a/packages/grafana-runtime/src/services/backendSrv.ts +++ b/packages/grafana-runtime/src/services/backendSrv.ts @@ -152,11 +152,12 @@ export interface BackendSrv { request(options: BackendSrvRequest): Promise; /** - * @deprecated Use the fetch function instead * Special function used to communicate with datasources that will emit core * events that the Grafana QueryInspector and QueryEditor is listening for to be able * to display datasource query information. Can be skipped by adding `option.silent` * when initializing the request. + * + * @deprecated Use the fetch function instead */ datasourceRequest(options: BackendSrvRequest): Promise>; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx index e5a89c9bb60..326305af2f9 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx @@ -268,7 +268,7 @@ export const DataSourceHttpSettings: React.FC = (props) => { )} - {dataSourceConfig.jsonData.sigV4Auth && } + {dataSourceConfig.jsonData.sigV4Auth && sigV4AuthToggleEnabled && } {(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && ( diff --git a/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx b/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx index 77369a6c3ba..e90a6cc7ee5 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx +++ b/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { dateTime, ArrayVector, FieldType, GraphSeriesXY, FieldColorModeId } from '@grafana/data'; +import { dateTime, ArrayVector, FieldType, GraphSeriesXY, FieldColorModeId, getDisplayProcessor } from '@grafana/data'; import { Story } from '@storybook/react'; +import { useTheme2 } from '../../themes'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { VizTooltip, TooltipDisplayMode, VizTooltipContentProps } from '../VizTooltip'; import { JSONFormatter } from '../JSONFormatter/JSONFormatter'; @@ -108,9 +109,19 @@ export default { }, }; -export const WithTooltip: Story = ({ tooltipMode, ...args }) => { +export const WithTooltip: Story = ({ + tooltipMode, + series, + ...args +}) => { + const theme = useTheme2(); + const seriesWithDisplay = series.map((data) => ({ + ...data, + valueField: { ...data.valueField, display: getDisplayProcessor({ field: data.valueField, theme }) }, + })); + return ( - + ); diff --git a/packages/grafana-ui/src/components/uPlot/models.cue b/packages/grafana-ui/src/components/uPlot/models.cue index e0913aa4235..5839ea927a0 100644 --- a/packages/grafana-ui/src/components/uPlot/models.cue +++ b/packages/grafana-ui/src/components/uPlot/models.cue @@ -6,6 +6,11 @@ DrawStyle: "line" | "bars" | "points" @cuetsy(targetType="enum") LineInterpolation: "linear" | "smooth" | "stepBefore" | "stepAfter" @cuetsy(targetType="enum") ScaleDistribution: "linear" | "log" | "ordinal" @cuetsy(targetType="enum") GraphGradientMode: "none" | "opacity" | "hue" | "scheme" @cuetsy(targetType="enum") +StackingMode: "none" | "normal" | "percent" @cuetsy(targetType="enum") +BarValueVisibility: "auto" | "never" | "always" @cuetsy(targetType="enum") +BarAlignment: -1 | 0 | 1 @cuetsy(targetType="enum",memberNames="Before|Center|After") +ScaleOrientation: 0 | 1 @cuetsy(targetType="enum",memberNames="Horizontal|Vertical") +ScaleDirection: 1 | 1 | -1 | -1 @cuetsy(targetType="enum",memberNames="Up|Right|Down|Left") LineStyle: { fill?: "solid" | "dash" | "dot" | "square" @@ -20,6 +25,12 @@ LineConfig: { spanNulls?: bool | number } @cuetsy(targetType="interface") +BarConfig: { + barAlignment?: BarAlignment + barWidthFactor?: number + barMaxWidth?: number +} @cuetsy(targetType="interface") + FillConfig: { fillColor?: string fillOpacity?: number @@ -53,11 +64,34 @@ HideSeriesConfig: { viz: bool } @cuetsy(targetType="interface") -// TODO This is the same composition as what's used in the timeseries panel's -// PanelFieldConfig. If that's the only place it's used, it probably shouldn't -// be assembled here, too -GraphFieldConfig: LineConfig & FillConfig & PointsConfig & AxisConfig & { - drawStyle?: DrawStyle - gradientMode?: GraphGradientMode +StackingConfig: { + mode?: StackingMode + group?: string +} @cuetsy(targetType="interface") + +StackableFieldConfig: { + stacking?: StackingConfig +} @cuetsy(targetType="interface") + +HideableFieldConfig: { hideFrom?: HideSeriesConfig } @cuetsy(targetType="interface") + +GraphTresholdsStyleMode: "off" | "line" | "area" | "line+area" | "series" @cuetsy(targetType="enum",memberNames="Off|Line|Area|LineAndArea|Series") + +GraphThresholdsStyleConfig: { + mode: GraphTresholdsStyleMode +} @cuetsy(targetType="interface") + +GraphFieldConfig: { + LineConfig + FillConfig + PointsConfig + AxisConfig + BarConfig + StackableFieldConfig + HideableFieldConfig + drawStyle?: DrawStyle + gradientMode?: GraphGradientMode + thresholdsStyle?: GraphThresholdsStyleConfig +} @cuetsy(targetType="interface") diff --git a/packages/grafana-ui/src/themes/GlobalStyles/elements.ts b/packages/grafana-ui/src/themes/GlobalStyles/elements.ts index 49f8f4d6dc5..8f2580de8bb 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/elements.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/elements.ts @@ -55,6 +55,9 @@ export function getElementStyles(theme: GrafanaTheme2) { button { letter-spacing: ${theme.typography.body.letterSpacing}; + &:focus-visible { + outline: ${getFocusStyles(theme)}; + } &:focus { outline: none; } diff --git a/pkg/infra/serverlock/serverlock.go b/pkg/infra/serverlock/serverlock.go index e662f85af4f..95b2027a81f 100644 --- a/pkg/infra/serverlock/serverlock.go +++ b/pkg/infra/serverlock/serverlock.go @@ -39,7 +39,7 @@ func (sl *ServerLockService) LockAndExecute(ctx context.Context, actionName stri // avoid execution if last lock happened less than `maxInterval` ago if rowLock.LastExecution != 0 { lastExecutionTime := time.Unix(rowLock.LastExecution, 0) - if lastExecutionTime.Unix() > time.Now().Add(-maxInterval).Unix() { + if time.Since(lastExecutionTime) < maxInterval { return nil } } diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go index c0709c4fc5c..3a793787dc1 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -11,6 +11,7 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" ) @@ -139,7 +140,7 @@ func (ds *DataSource) HTTPClientOptions() (*sdkhttpclient.Options, error) { } } - if ds.JsonData != nil && ds.JsonData.Get("sigV4Auth").MustBool(false) { + if ds.JsonData != nil && ds.JsonData.Get("sigV4Auth").MustBool(false) && setting.SigV4AuthEnabled { opts.SigV4 = &sdkhttpclient.SigV4Config{ Service: awsServiceNamespace(ds.Type), Region: ds.JsonData.Get("sigV4Region").MustString(), diff --git a/pkg/models/datasource_cache_test.go b/pkg/models/datasource_cache_test.go index fcb79e0d5fb..149b50bddf4 100644 --- a/pkg/models/datasource_cache_test.go +++ b/pkg/models/datasource_cache_test.go @@ -296,6 +296,12 @@ func TestDataSource_GetHttpTransport(t *testing.T) { }) clearDSProxyCache(t) + origSigV4Enabled := setting.SigV4AuthEnabled + setting.SigV4AuthEnabled = true + t.Cleanup(func() { + setting.SigV4AuthEnabled = origSigV4Enabled + }) + json, err := simplejson.NewJson([]byte(`{ "sigV4Auth": true }`)) require.NoError(t, err) diff --git a/pkg/schema/load/load_test.go b/pkg/schema/load/load_test.go index b75acfe0331..16d57a13f7f 100644 --- a/pkg/schema/load/load_test.go +++ b/pkg/schema/load/load_test.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "testing" "testing/fstest" @@ -49,17 +50,8 @@ func TestScuemataBasics(t *testing.T) { } func TestDevenvDashboardValidity(t *testing.T) { - // TODO un-skip when tests pass on all devenv dashboards - t.Skip() - // validdir := os.DirFS(filepath.Join("..", "..", "..", "devenv", "dev-dashboards")) validdir := filepath.Join("..", "..", "..", "devenv", "dev-dashboards") - dash, err := BaseDashboardFamily(p) - require.NoError(t, err, "error while loading base dashboard scuemata") - - ddash, err := DistDashboardFamily(p) - require.NoError(t, err, "error while loading dist dashboard scuemata") - doTest := func(sch schema.VersionedCueSchema) func(t *testing.T) { return func(t *testing.T) { t.Parallel() @@ -87,7 +79,9 @@ func TestDevenvDashboardValidity(t *testing.T) { return nil } else { if !(oldschemav.(float64) > 29) { - t.Logf("schemaVersion is %v, older than 30, skipping %s", oldschemav, path) + if testing.Verbose() { + t.Logf("schemaVersion is %v, older than 30, skipping %s", oldschemav, path) + } return nil } } @@ -96,7 +90,12 @@ func TestDevenvDashboardValidity(t *testing.T) { err := sch.Validate(schema.Resource{Value: byt, Name: path}) if err != nil { // Testify trims errors to short length. We want the full text - t.Fatal(errors.Details(err, nil)) + errstr := errors.Details(err, nil) + t.Log(errstr) + if strings.Contains(errstr, "null") { + t.Log("validation failure appears to involve nulls - see if scripts/stripnulls.sh has any effect?") + } + t.FailNow() } }) @@ -107,7 +106,15 @@ func TestDevenvDashboardValidity(t *testing.T) { // TODO will need to expand this appropriately when the scuemata contain // more than one schema - t.Run("base", doTest(dash)) + + // TODO disabled because base variant validation currently must fail in order for + // dist/instance validation to do closed validation of plugin-specified fields + // t.Run("base", doTest(dash)) + // dash, err := BaseDashboardFamily(p) + // require.NoError(t, err, "error while loading base dashboard scuemata") + + ddash, err := DistDashboardFamily(p) + require.NoError(t, err, "error while loading dist dashboard scuemata") t.Run("dist", doTest(ddash)) } diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index c2240df38d5..7dbe015ec6f 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/url" "time" @@ -10,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -43,6 +45,9 @@ type Alertmanager interface { // Alerts GetAlerts(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error) GetAlertGroups(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.AlertGroups, error) + + // Testing + TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigParams) (*notifier.TestReceiversResult, error) } // API handlers. diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 14f2f9d414d..205582e963f 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -1,9 +1,13 @@ package api import ( + "context" "errors" "fmt" "net/http" + "strconv" + "strings" + "time" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" @@ -15,12 +19,79 @@ import ( "github.com/grafana/grafana/pkg/util" ) +const ( + defaultTestReceiversTimeout = 15 * time.Second + maxTestReceiversTimeout = 30 * time.Second +) + type AlertmanagerSrv struct { am Alertmanager store store.AlertingStore log log.Logger } +type UnknownReceiverError struct { + UID string +} + +func (e UnknownReceiverError) Error() string { + return fmt.Sprintf("unknown receiver: %s", e.UID) +} + +func (srv AlertmanagerSrv) loadSecureSettings(orgId int64, receivers []*apimodels.PostableApiReceiver) error { + // Get the last known working configuration + query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: orgId} + if err := srv.store.GetLatestAlertmanagerConfiguration(&query); err != nil { + // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one + if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return fmt.Errorf("failed to get latest configuration: %w", err) + } + } + + currentReceiverMap := make(map[string]*apimodels.PostableGrafanaReceiver) + if query.Result != nil { + currentConfig, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration)) + if err != nil { + return fmt.Errorf("failed to load latest configuration: %w", err) + } + currentReceiverMap = currentConfig.GetGrafanaReceiverMap() + } + + // Copy the previously known secure settings + for i, r := range receivers { + for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { + if gr.UID == "" { // new receiver + continue + } + + cgmr, ok := currentReceiverMap[gr.UID] + if !ok { + // it tries to update a receiver that didn't previously exist + return UnknownReceiverError{UID: gr.UID} + } + + // frontend sends only the secure settings that have to be updated + // therefore we have to copy from the last configuration only those secure settings not included in the request + for key := range cgmr.SecureSettings { + _, ok := gr.SecureSettings[key] + if !ok { + decryptedValue, err := cgmr.GetDecryptedSecret(key) + if err != nil { + return fmt.Errorf("failed to decrypt stored secure setting: %s: %w", key, err) + } + + if receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil { + receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings)) + } + + receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue + } + } + } + } + return nil +} + func (srv AlertmanagerSrv) RouteGetAMStatus(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, srv.am.GetStatus()) } @@ -210,46 +281,12 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap } } - currentReceiverMap := make(map[string]*apimodels.PostableGrafanaReceiver) - if query.Result != nil { - currentConfig, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration)) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to load lastest configuration") - } - currentReceiverMap = currentConfig.GetGrafanaReceiverMap() - } - - // Copy the previously known secure settings - for i, r := range body.AlertmanagerConfig.Receivers { - for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { - if gr.UID == "" { // new receiver - continue - } - - cgmr, ok := currentReceiverMap[gr.UID] - if !ok { - // it tries to update a receiver that didn't previously exist - return ErrResp(http.StatusBadRequest, fmt.Errorf("unknown receiver: %s", gr.UID), "") - } - - // frontend sends only the secure settings that have to be updated - // therefore we have to copy from the last configuration only those secure settings not included in the request - for key := range cgmr.SecureSettings { - _, ok := body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] - if !ok { - decryptedValue, err := cgmr.GetDecryptedSecret(key) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to decrypt stored secure setting: %s", key) - } - - if body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil { - body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings)) - } - - body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue - } - } + if err := srv.loadSecureSettings(c.OrgId, body.AlertmanagerConfig.Receivers); err != nil { + var unknownReceiverError UnknownReceiverError + if errors.As(err, &unknownReceiverError) { + return ErrResp(http.StatusBadRequest, err, "") } + return ErrResp(http.StatusInternalServerError, err, "") } if err := body.ProcessConfig(); err != nil { @@ -265,6 +302,130 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap } func (srv AlertmanagerSrv) RoutePostAMAlerts(c *models.ReqContext, body apimodels.PostableAlerts) response.Response { - // not implemented return NotImplementedResp } + +func (srv AlertmanagerSrv) RoutePostTestReceivers(c *models.ReqContext, body apimodels.TestReceiversConfigParams) response.Response { + if !c.HasUserRole(models.ROLE_EDITOR) { + return accessForbiddenResp() + } + + if err := srv.loadSecureSettings(c.OrgId, body.Receivers); err != nil { + var unknownReceiverError UnknownReceiverError + if errors.As(err, &unknownReceiverError) { + return ErrResp(http.StatusBadRequest, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "") + } + + if err := body.ProcessConfig(); err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration") + } + + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + c.Req.Context(), + c.Req.Request, + defaultTestReceiversTimeout, + maxTestReceiversTimeout) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "") + } + defer cancelFunc() + + result, err := srv.am.TestReceivers(ctx, body) + if err != nil { + if errors.Is(err, notifier.ErrNoReceivers) { + return response.Error(http.StatusBadRequest, "", err) + } + return response.Error(http.StatusInternalServerError, "", err) + } + + return response.JSON(statusForTestReceivers(result.Receivers), newTestReceiversResult(result)) +} + +// contextWithTimeoutFromRequest returns a context with a deadline set from the +// Request-Timeout header in the HTTP request. If the header is absent then the +// context will use the default timeout. The timeout in the Request-Timeout +// header cannot exceed the maximum timeout. +func contextWithTimeoutFromRequest(ctx context.Context, r *http.Request, defaultTimeout, maxTimeout time.Duration) (context.Context, context.CancelFunc, error) { + timeout := defaultTimeout + if s := strings.TrimSpace(r.Header.Get("Request-Timeout")); s != "" { + // the timeout is measured in seconds + v, err := strconv.ParseInt(s, 10, 16) + if err != nil { + return nil, nil, err + } + if d := time.Duration(v) * time.Second; d < maxTimeout { + timeout = d + } else { + return nil, nil, fmt.Errorf("exceeded maximum timeout of %d seconds", maxTimeout) + } + } + ctx, cancelFunc := context.WithTimeout(ctx, timeout) + return ctx, cancelFunc, nil +} + +func newTestReceiversResult(r *notifier.TestReceiversResult) apimodels.TestReceiversResult { + v := apimodels.TestReceiversResult{ + Receivers: make([]apimodels.TestReceiverResult, len(r.Receivers)), + NotifedAt: r.NotifedAt, + } + for ix, next := range r.Receivers { + configs := make([]apimodels.TestReceiverConfigResult, len(next.Configs)) + for jx, config := range next.Configs { + configs[jx].Name = config.Name + configs[jx].UID = config.UID + configs[jx].Status = config.Status + if config.Error != nil { + configs[jx].Error = config.Error.Error() + } + } + v.Receivers[ix].Configs = configs + v.Receivers[ix].Name = next.Name + } + return v +} + +// statusForTestReceivers returns the appropriate status code for the response +// for the results. +// +// It returns an HTTP 200 OK status code if notifications were sent to all receivers, +// an HTTP 400 Bad Request status code if all receivers contain invalid configuration, +// an HTTP 408 Request Timeout status code if all receivers timed out when sending +// a test notification or an HTTP 207 Multi Status. +func statusForTestReceivers(v []notifier.TestReceiverResult) int { + var ( + numBadRequests int + numTimeouts int + numUnknownErrors int + ) + for _, receiver := range v { + for _, next := range receiver.Configs { + if next.Error != nil { + var ( + invalidReceiverErr notifier.InvalidReceiverError + receiverTimeoutErr notifier.ReceiverTimeoutError + ) + if errors.As(next.Error, &invalidReceiverErr) { + numBadRequests += 1 + } else if errors.As(next.Error, &receiverTimeoutErr) { + numTimeouts += 1 + } else { + numUnknownErrors += 1 + } + } + } + } + if numBadRequests == len(v) { + // if all receivers contain invalid configuration + return http.StatusBadRequest + } else if numTimeouts == len(v) { + // if all receivers contain valid configuration but timed out + return http.StatusRequestTimeout + } else if numBadRequests+numTimeouts+numUnknownErrors > 0 { + return http.StatusMultiStatus + } else { + // all receivers were sent a notification without error + return http.StatusOK + } +} diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go new file mode 100644 index 00000000000..525e7ddb88e --- /dev/null +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/stretchr/testify/require" +) + +func TestContextWithTimeoutFromRequest(t *testing.T) { + t.Run("assert context has default timeout when header is absent", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(t, err) + + now := time.Now() + ctx := context.Background() + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + ctx, + req, + 15*time.Second, + 30*time.Second) + require.NoError(t, err) + require.NotNil(t, cancelFunc) + require.NotNil(t, ctx) + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.True(t, deadline.After(now)) + require.Less(t, deadline.Sub(now).Seconds(), 30.0) + require.GreaterOrEqual(t, deadline.Sub(now).Seconds(), 15.0) + }) + + t.Run("assert context has timeout in request header", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(t, err) + req.Header.Set("Request-Timeout", "5") + + now := time.Now() + ctx := context.Background() + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + ctx, + req, + 15*time.Second, + 30*time.Second) + require.NoError(t, err) + require.NotNil(t, cancelFunc) + require.NotNil(t, ctx) + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.True(t, deadline.After(now)) + require.Less(t, deadline.Sub(now).Seconds(), 15.0) + require.GreaterOrEqual(t, deadline.Sub(now).Seconds(), 5.0) + }) + + t.Run("assert timeout in request header cannot exceed max timeout", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(t, err) + req.Header.Set("Request-Timeout", "60") + + ctx := context.Background() + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + ctx, + req, + 15*time.Second, + 30*time.Second) + require.Error(t, err, "exceeded maximum timeout") + require.Nil(t, cancelFunc) + require.Nil(t, ctx) + }) +} + +func TestStatusForTestReceivers(t *testing.T) { + t.Run("assert HTTP 400 Status Bad Request for no receivers", func(t *testing.T) { + require.Equal(t, http.StatusBadRequest, statusForTestReceivers([]notifier.TestReceiverResult{})) + }) + + t.Run("assert HTTP 400 Bad Request when all invalid receivers", func(t *testing.T) { + require.Equal(t, http.StatusBadRequest, statusForTestReceivers([]notifier.TestReceiverResult{{ + Name: "test1", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test1", + UID: "uid1", + Status: "failed", + Error: notifier.InvalidReceiverError{}, + }}, + }, { + Name: "test2", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test2", + UID: "uid2", + Status: "failed", + Error: notifier.InvalidReceiverError{}, + }}, + }})) + }) + + t.Run("assert HTTP 408 Request Timeout when all receivers timed out", func(t *testing.T) { + require.Equal(t, http.StatusRequestTimeout, statusForTestReceivers([]notifier.TestReceiverResult{{ + Name: "test1", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test1", + UID: "uid1", + Status: "failed", + Error: notifier.ReceiverTimeoutError{}, + }}, + }, { + Name: "test2", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test2", + UID: "uid2", + Status: "failed", + Error: notifier.ReceiverTimeoutError{}, + }}, + }})) + }) + + t.Run("assert 207 Multi Status for different errors", func(t *testing.T) { + require.Equal(t, http.StatusMultiStatus, statusForTestReceivers([]notifier.TestReceiverResult{{ + Name: "test1", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test1", + UID: "uid1", + Status: "failed", + Error: notifier.InvalidReceiverError{}, + }}, + }, { + Name: "test2", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test2", + UID: "uid2", + Status: "failed", + Error: notifier.ReceiverTimeoutError{}, + }}, + }})) + }) +} diff --git a/pkg/services/ngalert/api/forked_am.go b/pkg/services/ngalert/api/forked_am.go index 427125317a3..9936e1dcb63 100644 --- a/pkg/services/ngalert/api/forked_am.go +++ b/pkg/services/ngalert/api/forked_am.go @@ -146,3 +146,12 @@ func (am *ForkedAMSvc) RoutePostAMAlerts(ctx *models.ReqContext, body apimodels. return s.RoutePostAMAlerts(ctx, body) } + +func (am *ForkedAMSvc) RoutePostTestReceivers(ctx *models.ReqContext, body apimodels.TestReceiversConfigParams) response.Response { + s, err := am.getService(ctx) + if err != nil { + return ErrResp(400, err, "") + } + + return s.RoutePostTestReceivers(ctx, body) +} diff --git a/pkg/services/ngalert/api/generated_base_api_alertmanager.go b/pkg/services/ngalert/api/generated_base_api_alertmanager.go index f6d19b647f9..4b5ae0db470 100644 --- a/pkg/services/ngalert/api/generated_base_api_alertmanager.go +++ b/pkg/services/ngalert/api/generated_base_api_alertmanager.go @@ -31,6 +31,7 @@ type AlertmanagerApiService interface { RouteGetSilences(*models.ReqContext) response.Response RoutePostAMAlerts(*models.ReqContext, apimodels.PostableAlerts) response.Response RoutePostAlertingConfig(*models.ReqContext, apimodels.PostableUserConfig) response.Response + RoutePostTestReceivers(*models.ReqContext, apimodels.TestReceiversConfigParams) response.Response } func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApiService, m *metrics.Metrics) { @@ -137,5 +138,15 @@ func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApiService, m * m, ), ) + group.Post( + toMacaronPath("/api/alertmanager/{Recipient}/config/api/v1/receivers/test"), + binding.Bind(apimodels.TestReceiversConfigParams{}), + metrics.Instrument( + http.MethodPost, + "/api/alertmanager/{Recipient}/config/api/v1/receivers/test", + srv.RoutePostTestReceivers, + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/lotex_am.go b/pkg/services/ngalert/api/lotex_am.go index e8e5fac185f..86501d0f561 100644 --- a/pkg/services/ngalert/api/lotex_am.go +++ b/pkg/services/ngalert/api/lotex_am.go @@ -192,3 +192,7 @@ func (am *LotexAM) RoutePostAMAlerts(ctx *models.ReqContext, alerts apimodels.Po nil, ) } + +func (am *LotexAM) RoutePostTestReceivers(ctx *models.ReqContext, config apimodels.TestReceiversConfigParams) response.Response { + return NotImplementedResp +} diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 60da4024a45..74fa1491263 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "reflect" + "time" "github.com/go-openapi/strfmt" "github.com/pkg/errors" @@ -73,6 +74,17 @@ import ( // 200: alertGroups // 400: ValidationError +// swagger:route POST /api/alertmanager/{Recipient}/config/api/v1/receivers/test alertmanager RoutePostTestReceivers +// +// Test Grafana managed receivers without saving them. +// +// Responses: +// +// 200: Ack +// 207: MultiStatus +// 400: ValidationError +// 408: Failure + // swagger:route GET /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteGetSilences // // get silences @@ -105,6 +117,40 @@ import ( // 200: Ack // 400: ValidationError +// swagger:model +type TestReceiversConfig struct { + Receivers []*PostableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` +} + +// swagger:parameters RoutePostTestReceivers +type TestReceiversConfigParams struct { + Receivers []*PostableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` +} + +func (c *TestReceiversConfigParams) ProcessConfig() error { + return processReceiverConfigs(c.Receivers) +} + +// swagger:model +type TestReceiversResult struct { + Receivers []TestReceiverResult `json:"receivers"` + NotifedAt time.Time `json:"notified_at"` +} + +// swagger:model +type TestReceiverResult struct { + Name string `json:"name"` + Configs []TestReceiverConfigResult `json:"grafana_managed_receiver_configs"` +} + +// swagger:model +type TestReceiverConfigResult struct { + Name string `json:"name"` + UID string `json:"uid"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + // swagger:parameters RouteCreateSilence type CreateSilenceParams struct { // in:body @@ -345,39 +391,7 @@ func (c *PostableUserConfig) GetGrafanaReceiverMap() map[string]*PostableGrafana // ProcessConfig parses grafana receivers, encrypts secrets and assigns UUIDs (if they are missing) func (c *PostableUserConfig) ProcessConfig() error { - seenUIDs := make(map[string]struct{}) - // encrypt secure settings for storing them in DB - for _, r := range c.AlertmanagerConfig.Receivers { - switch r.Type() { - case GrafanaReceiverType: - for _, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { - for k, v := range gr.SecureSettings { - encryptedData, err := util.Encrypt([]byte(v), setting.SecretKey) - if err != nil { - return fmt.Errorf("failed to encrypt secure settings: %w", err) - } - gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData) - } - if gr.UID == "" { - retries := 5 - for i := 0; i < retries; i++ { - gen := util.GenerateShortUID() - _, ok := seenUIDs[gen] - if !ok { - gr.UID = gen - break - } - } - if gr.UID == "" { - return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries) - } - } - seenUIDs[gr.UID] = struct{}{} - } - default: - } - } - return nil + return processReceiverConfigs(c.AlertmanagerConfig.Receivers) } // MarshalYAML implements yaml.Marshaller. @@ -911,3 +925,39 @@ type GettableGrafanaReceivers struct { type PostableGrafanaReceivers struct { GrafanaManagedReceivers []*PostableGrafanaReceiver `yaml:"grafana_managed_receiver_configs,omitempty" json:"grafana_managed_receiver_configs,omitempty"` } + +func processReceiverConfigs(c []*PostableApiReceiver) error { + seenUIDs := make(map[string]struct{}) + // encrypt secure settings for storing them in DB + for _, r := range c { + switch r.Type() { + case GrafanaReceiverType: + for _, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { + for k, v := range gr.SecureSettings { + encryptedData, err := util.Encrypt([]byte(v), setting.SecretKey) + if err != nil { + return fmt.Errorf("failed to encrypt secure settings: %w", err) + } + gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData) + } + if gr.UID == "" { + retries := 5 + for i := 0; i < retries; i++ { + gen := util.GenerateShortUID() + _, ok := seenUIDs[gen] + if !ok { + gr.UID = gen + break + } + } + if gr.UID == "" { + return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries) + } + } + seenUIDs[gr.UID] = struct{}{} + } + default: + } + } + return nil +} diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 98cd43cc174..22596da3082 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -485,6 +485,49 @@ } } }, + "/api/alertmanager/{Recipient}/config/api/v1/receivers/test": { + "post": { + "tags": [ + "alertmanager" + ], + "summary": "Test Grafana managed receivers without saving them.", + "operationId": "RoutePostTestReceivers", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/PostableApiReceiver" + }, + "x-go-name": "Receivers", + "name": "receivers", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Ack", + "schema": { + "$ref": "#/definitions/Ack" + } + }, + "207": { + "$ref": "#/responses/MultiStatus" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + }, + "408": { + "description": "Failure", + "schema": { + "$ref": "#/definitions/Failure" + } + } + } + } + }, "/api/prometheus/{Recipient}/api/v1/alerts": { "get": { "description": "gets the current alerts", @@ -1707,6 +1750,7 @@ "enum": [ "Alerting" ], + "x-go-enum-desc": "Alerting AlertingErrState", "x-go-name": "ExecErrState" }, "id": { @@ -1735,6 +1779,7 @@ "NoData", "OK" ], + "x-go-enum-desc": "Alerting Alerting\nNoData NoData\nOK OK", "x-go-name": "NoDataState" }, "orgId": { @@ -2547,6 +2592,7 @@ "enum": [ "Alerting" ], + "x-go-enum-desc": "Alerting AlertingErrState", "x-go-name": "ExecErrState" }, "no_data_state": { @@ -2556,6 +2602,7 @@ "NoData", "OK" ], + "x-go-enum-desc": "Alerting Alerting\nNoData NoData\nOK OK", "x-go-name": "NoDataState" }, "title": { @@ -3229,6 +3276,76 @@ }, "x-go-package": "github.com/prometheus/common/config" }, + "TestReceiverConfigResult": { + "type": "object", + "properties": { + "error": { + "type": "string", + "x-go-name": "Error" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "status": { + "type": "string", + "x-go-name": "Status" + }, + "uid": { + "type": "string", + "x-go-name": "UID" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, + "TestReceiverResult": { + "type": "object", + "properties": { + "grafana_managed_receiver_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/TestReceiverConfigResult" + }, + "x-go-name": "Configs" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, + "TestReceiversConfig": { + "type": "object", + "properties": { + "receivers": { + "type": "array", + "items": { + "$ref": "#/definitions/PostableApiReceiver" + }, + "x-go-name": "Receivers" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, + "TestReceiversResult": { + "type": "object", + "properties": { + "notified_at": { + "type": "string", + "format": "date-time", + "x-go-name": "NotifedAt" + }, + "receivers": { + "type": "array", + "items": { + "$ref": "#/definitions/TestReceiverResult" + }, + "x-go-name": "Receivers" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "TestRulePayload": { "type": "object", "properties": { @@ -3483,11 +3600,12 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" }, + "x-go-name": "AlertGroups", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroups" }, "alertStatus": { @@ -3672,16 +3790,14 @@ "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" }, - "x-go-name": "GettableAlerts", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -3734,6 +3850,8 @@ "x-go-name": "UpdatedAt" } }, + "x-go-name": "GettableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { @@ -3872,6 +3990,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -3912,8 +4031,6 @@ "x-go-name": "StartsAt" } }, - "x-go-name": "PostableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/postableSilence" }, "receiver": { diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index f9f9a41b40e..58e828a5dec 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -106,7 +106,8 @@ type Alertmanager struct { dispatcherMetrics *dispatch.DispatcherMetrics reloadConfigMtx sync.RWMutex - config []byte + config *apimodels.PostableUserConfig + configHash [16]byte } func New(cfg *setting.Cfg, store store.AlertingStore, m *metrics.Metrics) (*Alertmanager, error) { @@ -166,7 +167,11 @@ func (am *Alertmanager) Ready() bool { am.reloadConfigMtx.RLock() defer am.reloadConfigMtx.RUnlock() - return len(am.config) > 0 + return am.ready() +} + +func (am *Alertmanager) ready() bool { + return am.config != nil } func (am *Alertmanager) Run(ctx context.Context) error { @@ -314,6 +319,32 @@ func (am *Alertmanager) SyncAndApplyConfigFromDatabase(orgID int64) error { return nil } +func (am *Alertmanager) getTemplate() (*template.Template, error) { + am.reloadConfigMtx.RLock() + defer am.reloadConfigMtx.RUnlock() + if !am.ready() { + return nil, errors.New("alertmanager is not initialized") + } + paths := make([]string, 0, len(am.config.TemplateFiles)) + for name := range am.config.TemplateFiles { + paths = append(paths, filepath.Join(am.WorkingDirPath(), name)) + } + return am.templateFromPaths(paths...) +} + +func (am *Alertmanager) templateFromPaths(paths ...string) (*template.Template, error) { + tmpl, err := template.FromGlobs(paths...) + if err != nil { + return nil, err + } + externalURL, err := url.Parse(am.Settings.AppURL) + if err != nil { + return nil, err + } + tmpl.ExternalURL = externalURL + return tmpl, nil +} + // applyConfig applies a new configuration by re-initializing all components using the configuration provided. // It is not safe to call concurrently. func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig []byte) (err error) { @@ -328,7 +359,7 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig rawConfig = enc } - if md5.Sum(am.config) != md5.Sum(rawConfig) { + if am.configHash != md5.Sum(rawConfig) { configChanged = true } @@ -350,15 +381,10 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig } // With the templates persisted, create the template list using the paths. - tmpl, err := template.FromGlobs(paths...) + tmpl, err := am.templateFromPaths(paths...) if err != nil { return err } - externalURL, err := url.Parse(am.Settings.AppURL) - if err != nil { - return err - } - tmpl.ExternalURL = externalURL // Finally, build the integrations map using the receiver configuration and templates. integrationsMap, err := am.buildIntegrationsMap(cfg.AlertmanagerConfig.Receivers, tmpl) @@ -400,7 +426,9 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig am.inhibitor.Run() }() - am.config = rawConfig + am.config = cfg + am.configHash = md5.Sum(rawConfig) + return nil } @@ -430,77 +458,95 @@ type NotificationChannel interface { // buildReceiverIntegrations builds a list of integration notifiers off of a receiver config. func (am *Alertmanager) buildReceiverIntegrations(receiver *apimodels.PostableApiReceiver, tmpl *template.Template) ([]notify.Integration, error) { var integrations []notify.Integration - for i, r := range receiver.GrafanaManagedReceivers { - // secure settings are already encrypted at this point - secureSettings := securejsondata.SecureJsonData(make(map[string][]byte, len(r.SecureSettings))) - - for k, v := range r.SecureSettings { - d, err := base64.StdEncoding.DecodeString(v) - if err != nil { - return nil, fmt.Errorf("failed to decode secure setting") - } - secureSettings[k] = d - } - var ( - cfg = &channels.NotificationChannelConfig{ - UID: r.UID, - Name: r.Name, - Type: r.Type, - DisableResolveMessage: r.DisableResolveMessage, - Settings: r.Settings, - SecureSettings: secureSettings, - } - n NotificationChannel - err error - ) - switch r.Type { - case "email": - n, err = channels.NewEmailNotifier(cfg, tmpl) // Email notifier already has a default template. - case "pagerduty": - n, err = channels.NewPagerdutyNotifier(cfg, tmpl) - case "pushover": - n, err = channels.NewPushoverNotifier(cfg, tmpl) - case "slack": - n, err = channels.NewSlackNotifier(cfg, tmpl) - case "telegram": - n, err = channels.NewTelegramNotifier(cfg, tmpl) - case "victorops": - n, err = channels.NewVictoropsNotifier(cfg, tmpl) - case "teams": - n, err = channels.NewTeamsNotifier(cfg, tmpl) - case "dingding": - n, err = channels.NewDingDingNotifier(cfg, tmpl) - case "kafka": - n, err = channels.NewKafkaNotifier(cfg, tmpl) - case "webhook": - n, err = channels.NewWebHookNotifier(cfg, tmpl) - case "sensugo": - n, err = channels.NewSensuGoNotifier(cfg, tmpl) - case "discord": - n, err = channels.NewDiscordNotifier(cfg, tmpl) - case "googlechat": - n, err = channels.NewGoogleChatNotifier(cfg, tmpl) - case "LINE": - n, err = channels.NewLineNotifier(cfg, tmpl) - case "threema": - n, err = channels.NewThreemaNotifier(cfg, tmpl) - case "opsgenie": - n, err = channels.NewOpsgenieNotifier(cfg, tmpl) - case "prometheus-alertmanager": - n, err = channels.NewAlertmanagerNotifier(cfg, tmpl) - default: - return nil, fmt.Errorf("notifier %s is not supported", r.Type) - } + n, err := am.buildReceiverIntegration(r, tmpl) if err != nil { return nil, err } integrations = append(integrations, notify.NewIntegration(n, n, r.Type, i)) } - return integrations, nil } +func (am *Alertmanager) buildReceiverIntegration(r *apimodels.PostableGrafanaReceiver, tmpl *template.Template) (NotificationChannel, error) { + // secure settings are already encrypted at this point + secureSettings := securejsondata.SecureJsonData(make(map[string][]byte, len(r.SecureSettings))) + + for k, v := range r.SecureSettings { + d, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, InvalidReceiverError{ + Receiver: r, + Err: errors.New("failed to decode secure setting"), + } + } + secureSettings[k] = d + } + + var ( + cfg = &channels.NotificationChannelConfig{ + UID: r.UID, + Name: r.Name, + Type: r.Type, + DisableResolveMessage: r.DisableResolveMessage, + Settings: r.Settings, + SecureSettings: secureSettings, + } + n NotificationChannel + err error + ) + switch r.Type { + case "email": + n, err = channels.NewEmailNotifier(cfg, tmpl) // Email notifier already has a default template. + case "pagerduty": + n, err = channels.NewPagerdutyNotifier(cfg, tmpl) + case "pushover": + n, err = channels.NewPushoverNotifier(cfg, tmpl) + case "slack": + n, err = channels.NewSlackNotifier(cfg, tmpl) + case "telegram": + n, err = channels.NewTelegramNotifier(cfg, tmpl) + case "victorops": + n, err = channels.NewVictoropsNotifier(cfg, tmpl) + case "teams": + n, err = channels.NewTeamsNotifier(cfg, tmpl) + case "dingding": + n, err = channels.NewDingDingNotifier(cfg, tmpl) + case "kafka": + n, err = channels.NewKafkaNotifier(cfg, tmpl) + case "webhook": + n, err = channels.NewWebHookNotifier(cfg, tmpl) + case "sensugo": + n, err = channels.NewSensuGoNotifier(cfg, tmpl) + case "discord": + n, err = channels.NewDiscordNotifier(cfg, tmpl) + case "googlechat": + n, err = channels.NewGoogleChatNotifier(cfg, tmpl) + case "LINE": + n, err = channels.NewLineNotifier(cfg, tmpl) + case "threema": + n, err = channels.NewThreemaNotifier(cfg, tmpl) + case "opsgenie": + n, err = channels.NewOpsgenieNotifier(cfg, tmpl) + case "prometheus-alertmanager": + n, err = channels.NewAlertmanagerNotifier(cfg, tmpl) + default: + return nil, InvalidReceiverError{ + Receiver: r, + Err: fmt.Errorf("notifier %s is not supported", r.Type), + } + } + + if err != nil { + return nil, InvalidReceiverError{ + Receiver: r, + Err: err, + } + } + + return n, nil +} + // PutAlerts receives the alerts and then sends them through the corresponding route based on whenever the alert has a receiver embedded or not func (am *Alertmanager) PutAlerts(postableAlerts apimodels.PostableAlerts) error { now := time.Now() diff --git a/pkg/services/ngalert/notifier/channels/webhook.go b/pkg/services/ngalert/notifier/channels/webhook.go index 63d6107fdf6..13c0d1367f1 100644 --- a/pkg/services/ngalert/notifier/channels/webhook.go +++ b/pkg/services/ngalert/notifier/channels/webhook.go @@ -31,6 +31,9 @@ type WebhookNotifier struct { // NewWebHookNotifier is the constructor for // the WebHook notifier. func NewWebHookNotifier(model *NotificationChannelConfig, t *template.Template) (*WebhookNotifier, error) { + if model.Settings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "could not find settings property"} + } url := model.Settings.Get("url").MustString() if url == "" { return nil, receiverInitError{Cfg: *model, Reason: "could not find url property in settings"} diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/receivers.go new file mode 100644 index 00000000000..3e6e9230651 --- /dev/null +++ b/pkg/services/ngalert/notifier/receivers.go @@ -0,0 +1,227 @@ +package notifier + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + "golang.org/x/sync/errgroup" +) + +const ( + maxTestReceiversWorkers = 10 +) + +var ( + ErrNoReceivers = errors.New("no receivers") +) + +type TestReceiversResult struct { + Receivers []TestReceiverResult + NotifedAt time.Time +} + +type TestReceiverResult struct { + Name string + Configs []TestReceiverConfigResult +} + +type TestReceiverConfigResult struct { + Name string + UID string + Status string + Error error +} + +type InvalidReceiverError struct { + Receiver *apimodels.PostableGrafanaReceiver + Err error +} + +func (e InvalidReceiverError) Error() string { + return fmt.Sprintf("the receiver is invalid: %s", e.Err) +} + +type ReceiverTimeoutError struct { + Receiver *apimodels.PostableGrafanaReceiver + Err error +} + +func (e ReceiverTimeoutError) Error() string { + return fmt.Sprintf("the receiver timed out: %s", e.Err) +} + +func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigParams) (*TestReceiversResult, error) { + // now represents the start time of the test + now := time.Now() + testAlert := &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + model.LabelName("alertname"): "TestAlertAlwaysFiring", + model.LabelName("instance"): "Grafana", + }, + Annotations: model.LabelSet{ + model.LabelName("summary"): "TestAlertAlwaysFiring", + model.LabelName("description"): "This is a test alert from Grafana", + }, + StartsAt: now, + }, + UpdatedAt: now, + } + + // we must set a group key that is unique per test as some receivers use this key to deduplicate alerts + ctx = notify.WithGroupKey(ctx, testAlert.Labels.String()+now.String()) + + tmpl, err := am.getTemplate() + if err != nil { + return nil, fmt.Errorf("failed to get template: %w", err) + } + + // job contains all metadata required to test a receiver + type job struct { + Config *apimodels.PostableGrafanaReceiver + ReceiverName string + Notifier notify.Notifier + } + + // result contains the receiver that was tested and an error that is non-nil if the test failed + type result struct { + Config *apimodels.PostableGrafanaReceiver + ReceiverName string + Error error + } + + newTestReceiversResult := func(results []result, notifiedAt time.Time) *TestReceiversResult { + m := make(map[string]TestReceiverResult) + for _, receiver := range c.Receivers { + // set up the result for this receiver + m[receiver.Name] = TestReceiverResult{ + Name: receiver.Name, + // A Grafana receiver can have multiple nested receivers + Configs: make([]TestReceiverConfigResult, 0, len(receiver.GrafanaManagedReceivers)), + } + } + for _, next := range results { + tmp := m[next.ReceiverName] + status := "ok" + if next.Error != nil { + status = "failed" + } + tmp.Configs = append(tmp.Configs, TestReceiverConfigResult{ + Name: next.Config.Name, + UID: next.Config.UID, + Status: status, + Error: processNotifierError(next.Config, next.Error), + }) + m[next.ReceiverName] = tmp + } + v := new(TestReceiversResult) + v.Receivers = make([]TestReceiverResult, 0, len(c.Receivers)) + v.NotifedAt = notifiedAt + for _, next := range m { + v.Receivers = append(v.Receivers, next) + } + return v + } + + // invalid keeps track of all invalid receiver configurations + invalid := make([]result, 0, len(c.Receivers)) + // jobs keeps track of all receivers that need to be sent test notifications + jobs := make([]job, 0, len(c.Receivers)) + + for _, receiver := range c.Receivers { + for _, next := range receiver.GrafanaManagedReceivers { + n, err := am.buildReceiverIntegration(next, tmpl) + if err != nil { + invalid = append(invalid, result{ + Config: next, + ReceiverName: next.Name, + Error: err, + }) + } else { + jobs = append(jobs, job{ + Config: next, + ReceiverName: receiver.Name, + Notifier: n, + }) + } + } + } + + if len(invalid)+len(jobs) == 0 { + return nil, ErrNoReceivers + } + + if len(jobs) == 0 { + return newTestReceiversResult(invalid, now), nil + } + + numWorkers := maxTestReceiversWorkers + if numWorkers > len(jobs) { + numWorkers = len(jobs) + } + + resultCh := make(chan result, len(jobs)) + workCh := make(chan job, len(jobs)) + for _, job := range jobs { + workCh <- job + } + close(workCh) + + g, ctx := errgroup.WithContext(ctx) + for i := 0; i < numWorkers; i++ { + g.Go(func() error { + for next := range workCh { + v := result{ + Config: next.Config, + ReceiverName: next.ReceiverName, + } + if _, err := next.Notifier.Notify(ctx, testAlert); err != nil { + v.Error = err + } + resultCh <- v + } + return nil + }) + } + g.Wait() // nolint + close(resultCh) + + results := make([]result, 0, len(jobs)) + for next := range resultCh { + results = append(results, next) + } + + return newTestReceiversResult(append(invalid, results...), now), nil +} + +func processNotifierError(config *apimodels.PostableGrafanaReceiver, err error) error { + if err == nil { + return nil + } + + var urlError *url.Error + if errors.As(err, &urlError) { + if urlError.Timeout() { + return ReceiverTimeoutError{ + Receiver: config, + Err: err, + } + } + } + + if errors.Is(err, context.DeadlineExceeded) { + return ReceiverTimeoutError{ + Receiver: config, + Err: err, + } + } + + return err +} diff --git a/pkg/services/ngalert/notifier/receivers_test.go b/pkg/services/ngalert/notifier/receivers_test.go new file mode 100644 index 00000000000..136d3eebab0 --- /dev/null +++ b/pkg/services/ngalert/notifier/receivers_test.go @@ -0,0 +1,82 @@ +package notifier + +import ( + "context" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +func TestInvalidReceiverError_Error(t *testing.T) { + e := InvalidReceiverError{ + Receiver: &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + }, + Err: errors.New("this is an error"), + } + require.Equal(t, "the receiver is invalid: this is an error", e.Error()) +} + +func TestReceiverTimeoutError_Error(t *testing.T) { + e := ReceiverTimeoutError{ + Receiver: &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + }, + Err: errors.New("context deadline exceeded"), + } + require.Equal(t, "the receiver timed out: context deadline exceeded", e.Error()) +} + +type timeoutError struct{} + +func (e timeoutError) Error() string { + return "the request timed out" +} + +func (e timeoutError) Timeout() bool { + return true +} + +func TestProcessNotifierError(t *testing.T) { + t.Run("assert ReceiverTimeoutError is returned for context deadline exceeded", func(t *testing.T) { + r := &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + } + require.Equal(t, ReceiverTimeoutError{ + Receiver: r, + Err: context.DeadlineExceeded, + }, processNotifierError(r, context.DeadlineExceeded)) + }) + + t.Run("assert ReceiverTimeoutError is returned for *url.Error timeout", func(t *testing.T) { + r := &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + } + urlError := &url.Error{ + Op: "Get", + URL: "https://grafana.net", + Err: timeoutError{}, + } + require.Equal(t, ReceiverTimeoutError{ + Receiver: r, + Err: urlError, + }, processNotifierError(r, urlError)) + }) + + t.Run("assert unknown error is returned unmodified", func(t *testing.T) { + r := &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + } + err := errors.New("this is an error") + require.Equal(t, err, processNotifierError(r, err)) + }) +} diff --git a/pkg/services/ngalert/notifier/status.go b/pkg/services/ngalert/notifier/status.go index 8726166dcbe..eef11ab48cf 100644 --- a/pkg/services/ngalert/notifier/status.go +++ b/pkg/services/ngalert/notifier/status.go @@ -1,8 +1,6 @@ package notifier import ( - "encoding/json" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -10,13 +8,9 @@ func (am *Alertmanager) GetStatus() apimodels.GettableStatus { am.reloadConfigMtx.RLock() defer am.reloadConfigMtx.RUnlock() - var amConfig apimodels.PostableApiAlertingConfig - if am.config != nil { - err := json.Unmarshal(am.config, &amConfig) - if err != nil { - // this should never error here, if the configuration is running it should be valid. - am.logger.Error("unable to marshal alertmanager configuration", "err", err) - } + config := apimodels.PostableApiAlertingConfig{} + if am.ready() { + config = am.config.AlertmanagerConfig } - return *apimodels.NewGettableStatus(&amConfig) + return *apimodels.NewGettableStatus(&config) } diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index ef9dbd108b6..643cda7f0ac 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -85,7 +85,7 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { } ` resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint - require.JSONEq(t, `{"message":"failed to save and apply Alertmanager configuration: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body)) + require.JSONEq(t, `{"message":"failed to save and apply Alertmanager configuration: the receiver is invalid: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body)) resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body)) diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 22e111e71d4..1b2750e8303 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -30,6 +30,328 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) +func TestTestReceivers(t *testing.T) { + t.Run("assert no receivers returns 400 Bad Request", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + // nolint + resp := postRequest(t, testReceiversURL, `{ + "receivers": [] +}`, http.StatusBadRequest) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq(t, `{"error":"no receivers"}`, string(b)) + }) + + t.Run("assert working receiver returns OK", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandler{} + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + // nolint + resp := postRequest(t, testReceiversURL, `{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{ + "addresses":"example@email.com" + }, + "secureFields":{} + } + ] + }] +}`, http.StatusOK) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.TestReceiversResult + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + require.Len(t, result.Receivers, 1) + require.Len(t, result.Receivers[0].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "ok", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + + require.Len(t, mockEmails.emails, 1) + require.Equal(t, []string{"example@email.com"}, mockEmails.emails[0].To) + }) + + t.Run("assert invalid receiver returns 400 Bad Request", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandler{} + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + // nolint + resp := postRequest(t, testReceiversURL, `{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{}, + "secureFields":{} + } + ] + }] +}`, http.StatusBadRequest) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) + + var result apimodels.TestReceiversResult + require.NoError(t, json.Unmarshal(b, &result)) + require.Len(t, result.Receivers, 1) + require.Len(t, result.Receivers[0].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "failed", + Error: "the receiver is invalid: failed to validate receiver \"receiver-1\" of type \"email\": could not find addresses in settings", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + }) + + t.Run("assert timed out receiver returns 408 Request Timeout", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandlerWithTimeout{ + timeout: 5 * time.Second, + } + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + req, err := http.NewRequest(http.MethodPost, testReceiversURL, strings.NewReader(`{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{ + "addresses":"example@email.com" + }, + "secureFields":{} + } + ] + }] +}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Request-Timeout", "1") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) + require.Equal(t, http.StatusRequestTimeout, resp.StatusCode) + + var result apimodels.TestReceiversResult + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + require.Len(t, result.Receivers, 1) + require.Len(t, result.Receivers[0].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "failed", + Error: "the receiver timed out: context deadline exceeded", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + }) + + t.Run("assert multiple different errors returns 207 Multi Status", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandlerWithTimeout{ + timeout: 5 * time.Second, + } + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + req, err := http.NewRequest(http.MethodPost, testReceiversURL, strings.NewReader(`{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{}, + "secureFields":{} + } + ] + }, { + "name":"receiver-2", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-2", + "type":"email", + "disableResolveMessage":false, + "settings":{ + "addresses":"example@email.com" + }, + "secureFields":{} + } + ] + }] +}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Request-Timeout", "1") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) + require.Equal(t, http.StatusMultiStatus, resp.StatusCode) + + var result apimodels.TestReceiversResult + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + require.Len(t, result.Receivers, 2) + require.Len(t, result.Receivers[0].Configs, 1) + require.Len(t, result.Receivers[1].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "failed", + Error: "the receiver is invalid: failed to validate receiver \"receiver-1\" of type \"email\": could not find addresses in settings", + }}, + }, { + Name: "receiver-2", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-2", + UID: result.Receivers[1].Configs[0].UID, + Status: "failed", + Error: "the receiver timed out: context deadline exceeded", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + }) +} + func TestNotificationChannels(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, @@ -391,6 +713,21 @@ func (e *mockEmailHandler) sendEmailCommandHandlerSync(_ context.Context, cmd *m return nil } +// mockEmailHandlerWithTimeout blocks until the timeout has expired. +type mockEmailHandlerWithTimeout struct { + mockEmailHandler + timeout time.Duration +} + +func (e *mockEmailHandlerWithTimeout) sendEmailCommandHandlerSync(ctx context.Context, cmd *models.SendEmailCommandSync) error { + select { + case <-time.After(e.timeout): + return e.mockEmailHandler.sendEmailCommandHandlerSync(ctx, cmd) + case <-ctx.Done(): + return ctx.Err() + } +} + // alertmanagerConfig has the config for all the notification channels // that we want to test. It is recommended to use different URL for each // channel and have 1 route per channel. diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index cb80e70dc19..4315ffc3594 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -60,7 +60,7 @@ var metricsMap = map[string][]string{ "AWS/CloudSearch": {"IndexUtilization", "Partitions", "SearchableDocuments", "SuccessfulRequests"}, "AWS/CodeBuild": {"BuildDuration", "Builds", "DownloadSourceDuration", "Duration", "FailedBuilds", "FinalizingDuration", "InstallDuration", "PostBuildDuration", "PreBuildDuration", "ProvisioningDuration", "QueuedDuration", "SubmittedDuration", "SucceededBuilds", "UploadArtifactsDuration"}, "AWS/CodeGuruProfiler": {"Recommendations"}, - "AWS/Cognito": {"AccountTakeOverRisk", "CompromisedCredentialsRisk", "NoRisk", "OverrideBlock", "Risk"}, + "AWS/Cognito": {"AccountTakeOverRisk", "CompromisedCredentialsRisk", "NoRisk", "OverrideBlock", "Risk", "SignUpSuccesses", "SignUpThrottles", "SignInSuccesses", "SignInThrottles", "TokenRefreshSuccesses", "TokenRefreshThrottles", "FederationSuccesses", "FederationThrottles"}, "AWS/Connect": {"CallBackNotDialableNumber", "CallRecordingUploadError", "CallsBreachingConcurrencyQuota", "CallsPerInterval", "ConcurrentCalls", "ConcurrentCallsPercentage", "ContactFlowErrors", "ContactFlowFatalErrors", "LongestQueueWaitTime", "MisconfiguredPhoneNumbers", "MissedCalls", "PublicSigningKeyUsage", "QueueCapacityExceededError", "QueueSize", "ThrottledCalls", "ToInstancePacketLossRate"}, "AWS/DataSync": {"BytesVerifiedSource", "BytesPreparedSource", "FilesVerifiedSource", "FilesPreparedSource", "BytesVerifiedDestination", "BytesPreparedDestination", "FilesVerifiedDestination", "FilesPreparedDestination", "FilesTransferred", "BytesTransferred", "BytesWritten"}, "AWS/DDoSProtection": {"DDoSDetected", "DDoSAttackBitsPerSecond", "DDoSAttackPacketsPerSecond", "DDoSAttackRequestsPerSecond", "VolumeBitsPerSecond", "VolumePacketsPerSecond"}, @@ -164,7 +164,7 @@ var dimensionsMap = map[string][]string{ "AWS/CloudSearch": {"ClientId", "DomainName"}, "AWS/CodeBuild": {"ProjectName"}, "AWS/CodeGuruProfiler": {}, - "AWS/Cognito": {"Operation", "RiskLevel", "UserPoolId"}, + "AWS/Cognito": {"Operation", "RiskLevel", "UserPoolId", "UserPool", "UserPoolClient", "IdentityProvider"}, "AWS/Connect": {"InstanceId", "MetricGroup", "ContactFlowName", "SigningKeyId", "TypeOfConnection", "Participant", "QueueName", "StreamType"}, "AWS/DataSync": {"AgentId", "TaskId"}, "AWS/DDoSProtection": {"ResourceArn", "AttackVector", "MitigationAction", "Protocol", "SourcePort", "DestinationPort", "SourceIp", "SourceAsn", "TcpFlags"}, diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 47ddbd86629..7fe796e946b 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -42,7 +42,7 @@ var newResponseParser = func(responses []*es.SearchResponse, targets []*Query, d } } -// nolint:staticcheck // plugins.DataResponse deprecated +// nolint:staticcheck func (rp *responseParser) getTimeSeries() (*backend.QueryDataResponse, error) { result := backend.QueryDataResponse{ Responses: backend.Responses{}, @@ -93,7 +93,7 @@ func (rp *responseParser) getTimeSeries() (*backend.QueryDataResponse, error) { return &result, nil } -// nolint:staticcheck // plugins.* deprecated +// nolint:staticcheck func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Query, queryResult *backend.DataResponse, props map[string]string, depth int) error { var err error @@ -172,7 +172,7 @@ func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Qu return nil } -// nolint:staticcheck,gocyclo // plugins.* deprecated +// nolint:staticcheck,gocyclo func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, query *backend.DataResponse, props map[string]string) error { frames := data.Frames{} @@ -203,7 +203,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, tags["metric"] = countType frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", tags, values))) case percentilesType: buckets := esAggBuckets if len(buckets) == 0 { @@ -237,7 +237,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, } frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", tags, values))) } case topMetricsType: buckets := esAggBuckets @@ -279,7 +279,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, frames = append(frames, data.NewFrame(metricField.(string), data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metricField.(string)}), + data.NewField("value", tags, values), )) } @@ -326,7 +326,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, labels := tags frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", labels, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", labels, values))) } default: for k, v := range props { @@ -354,7 +354,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, } frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", tags, values))) } } if query.Frames != nil { @@ -365,7 +365,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, return nil } -// nolint:staticcheck // plugins.* deprecated +// nolint:staticcheck func (rp *responseParser) processAggregationDocs(esAgg *simplejson.Json, aggDef *BucketAgg, target *Query, queryResult *backend.DataResponse, props map[string]string) error { propKeys := make([]string, 0) @@ -517,8 +517,7 @@ func extractDataField(name string, v interface{}) *data.Field { } } -// TODO remove deprecations -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (rp *responseParser) trimDatapoints(queryResult backend.DataResponse, target *Query) { var histogram *BucketAgg for _, bucketAgg := range target.BucketAggs { @@ -552,7 +551,7 @@ func (rp *responseParser) trimDatapoints(queryResult backend.DataResponse, targe } } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (rp *responseParser) nameFields(queryResult backend.DataResponse, target *Query) { set := make(map[string]struct{}) frames := queryResult.Frames @@ -568,12 +567,15 @@ func (rp *responseParser) nameFields(queryResult backend.DataResponse, target *Q metricTypeCount := len(set) for i := range frames { frames[i].Name = rp.getFieldName(*frames[i].Fields[1], target, metricTypeCount) + for _, field := range frames[i].Fields { + field.SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getFieldName(*frames[i].Fields[1], target, metricTypeCount)}) + } } } var aliasPatternRegex = regexp.MustCompile(`\{\{([\s\S]+?)\}\}`) -// nolint:staticcheck // plugins.* deprecated +// nolint:staticcheck func (rp *responseParser) getFieldName(dataField data.Field, target *Query, metricTypeCount int) string { metricType := dataField.Labels["metric"] metricName := rp.getMetricName(metricType) @@ -706,7 +708,7 @@ func findAgg(target *Query, aggID string) (*BucketAgg, error) { return nil, errors.New("can't found aggDef, aggID:" + aggID) } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func getErrorFromElasticResponse(response *es.SearchResponse) string { var errorString string json := simplejson.NewFromAny(response.Error) diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go index 6b7e7ea3f3e..4caf3cdde4d 100644 --- a/pkg/tsdb/elasticsearch/time_series_query.go +++ b/pkg/tsdb/elasticsearch/time_series_query.go @@ -28,7 +28,7 @@ var newTimeSeriesQuery = func(client es.Client, dataQuery []backend.DataQuery, } } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (e *timeSeriesQuery) execute() (*backend.QueryDataResponse, error) { tsQueryParser := newTimeSeriesQueryParser() queries, err := tsQueryParser.parse(e.dataQueries) @@ -63,7 +63,7 @@ func (e *timeSeriesQuery) execute() (*backend.QueryDataResponse, error) { return rp.getTimeSeries() } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (e *timeSeriesQuery) processQuery(q *Query, ms *es.MultiSearchRequestBuilder, from, to string, result backend.QueryDataResponse) error { minInterval, err := e.client.GetMinInterval(q.Interval) diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index f374d484461..a2600fd80af 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -56,6 +56,7 @@ type ResponseModel struct { LegendFormat string `json:"legendFormat"` Interval string `json:"interval"` IntervalMS int `json:"intervalMS"` + Resolution int64 `json:"resolution"` } func init() { @@ -210,7 +211,12 @@ func (s *Service) parseQuery(dsInfo *datasourceInfo, queryContext *backend.Query return nil, err } - step := time.Duration(int64(interval.Value)) + var resolution int64 = 1 + if model.Resolution >= 1 && model.Resolution <= 5 || model.Resolution == 10 { + resolution = model.Resolution + } + + step := time.Duration(int64(interval.Value) * resolution) qs = append(qs, &lokiQuery{ Expr: model.Expr, diff --git a/public/app/core/components/sidemenu/SideMenu.tsx b/public/app/core/components/sidemenu/SideMenu.tsx index 026c9e150e7..316ed7425c9 100644 --- a/public/app/core/components/sidemenu/SideMenu.tsx +++ b/public/app/core/components/sidemenu/SideMenu.tsx @@ -24,7 +24,7 @@ export const SideMenu: FC = React.memo(() => { } return ( -