Merge branch 'main' into ifrost/graphite-on-react-6

# Conflicts:
#	public/app/core/angular_wrappers.ts
#	public/app/plugins/datasource/graphite/components/GraphiteQueryEditor.tsx
#	public/app/plugins/datasource/graphite/components/SeriesSection.tsx
#	public/app/plugins/datasource/graphite/components/TagsSection.tsx
#	public/app/plugins/datasource/graphite/module.ts
#	public/app/plugins/datasource/graphite/query_ctrl.ts
#	public/app/plugins/datasource/graphite/state/context.tsx
This commit is contained in:
Piotr Jamróz
2021-08-17 19:06:30 +02:00
103 changed files with 3062 additions and 720 deletions
+2 -2
View File
@@ -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
...
+2 -3
View File
@@ -1,12 +1,11 @@
<!-- 8.1.1 START -->
# 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)
<!-- 8.1.1 END -->
<!-- 8.1.0 START -->
+1 -1
View File
@@ -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.
+14 -3
View File
@@ -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: {
+99 -16
View File
@@ -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")
@@ -354,7 +354,7 @@
"orientation": "auto",
"showValue": "auto",
"text": {
"size": 10,
"titleSize": 10,
"valueSize": 25
},
"tooltip": {
@@ -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
}
}
@@ -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
}
@@ -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
},
@@ -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
}
@@ -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
}
+1 -1
View File
@@ -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
-4
View File
@@ -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
@@ -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)
+1
View File
@@ -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",
@@ -48,6 +48,7 @@ export interface FeatureToggles {
ngalert: boolean;
trimDefaults: boolean;
accesscontrol: boolean;
tempoServiceGraph: boolean;
}
/**
@@ -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',
+1
View File
@@ -63,6 +63,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
ngalert: false,
accesscontrol: false,
trimDefaults: false,
tempoServiceGraph: false,
};
licenseInfo: LicenseInfo = {} as LicenseInfo;
rendererAvailable = false;
@@ -152,11 +152,12 @@ export interface BackendSrv {
request(options: BackendSrvRequest): Promise<any>;
/**
* @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<T = any>(options: BackendSrvRequest): Promise<FetchResponse<T>>;
@@ -268,7 +268,7 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = (props) => {
<azureAuthSettings.azureSettingsUI dataSourceConfig={dataSourceConfig} onChange={onChange} />
)}
{dataSourceConfig.jsonData.sigV4Auth && <SigV4AuthSettings {...props} />}
{dataSourceConfig.jsonData.sigV4Auth && sigV4AuthToggleEnabled && <SigV4AuthSettings {...props} />}
{(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && (
<TLSAuthSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
@@ -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<GraphProps & { tooltipMode: TooltipDisplayMode }> = ({ tooltipMode, ...args }) => {
export const WithTooltip: Story<GraphProps & { tooltipMode: TooltipDisplayMode }> = ({
tooltipMode,
series,
...args
}) => {
const theme = useTheme2();
const seriesWithDisplay = series.map((data) => ({
...data,
valueField: { ...data.valueField, display: getDisplayProcessor({ field: data.valueField, theme }) },
}));
return (
<Graph {...args}>
<Graph series={seriesWithDisplay} {...args}>
<VizTooltip mode={tooltipMode} />
</Graph>
);
@@ -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")
@@ -55,6 +55,9 @@ export function getElementStyles(theme: GrafanaTheme2) {
button {
letter-spacing: ${theme.typography.body.letterSpacing};
&:focus-visible {
outline: ${getFocusStyles(theme)};
}
&:focus {
outline: none;
}
+1 -1
View File
@@ -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
}
}
+2 -1
View File
@@ -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(),
+6
View File
@@ -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)
+19 -12
View File
@@ -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))
}
+5
View File
@@ -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.
+201 -40
View File
@@ -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
}
}
@@ -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{},
}},
}}))
})
}
+9
View File
@@ -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)
}
@@ -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)
}
+4
View File
@@ -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
}
@@ -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
}
+123 -6
View File
@@ -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": {
+118 -72
View File
@@ -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()
@@ -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"}
+227
View File
@@ -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
}
@@ -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))
})
}
+4 -10
View File
@@ -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)
}
@@ -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))
@@ -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.
+2 -2
View File
@@ -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"},
+16 -14
View File
@@ -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)
+2 -2
View File
@@ -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)
+7 -1
View File
@@ -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,
@@ -24,7 +24,7 @@ export const SideMenu: FC = React.memo(() => {
}
return (
<nav className="sidemenu" data-testid="sidemenu">
<nav className="sidemenu" data-testid="sidemenu" aria-label="Main menu">
<a href={homeUrl} className="sidemenu__logo" key="logo">
<Branding.MenuLogo />
</a>
@@ -1,5 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import { render, screen } from '@testing-library/react';
import TopSection from './TopSection';
jest.mock('../../config', () => ({
@@ -9,33 +9,21 @@ jest.mock('../../config', () => ({
{ id: '2', hideFromMenu: true },
{ id: '3', hideFromMenu: false },
{ id: '4', hideFromMenu: true },
{ id: '4', hideFromMenu: false },
],
},
}));
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
mainLinks: [],
},
propOverrides
);
return shallow(<TopSection {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
it('should render search when empty', () => {
render(<TopSection />);
expect(wrapper).toMatchSnapshot();
expect(screen.getByText('Search dashboards')).toBeInTheDocument();
});
it('should render items', () => {
const wrapper = setup({
mainLinks: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }],
});
it('should render items and search item', () => {
render(<TopSection />);
expect(wrapper).toMatchSnapshot();
expect(screen.getByTestId('top-section-items').children.length).toBe(3);
});
});
@@ -8,7 +8,7 @@ const TopSection: FC<any> = () => {
const navTree = cloneDeep(config.bootData.navTree);
const mainLinks = filter(navTree, (item) => !item.hideFromMenu);
const searchLink = {
text: 'Search',
text: 'Search dashboards',
icon: 'search',
};
@@ -17,7 +17,7 @@ const TopSection: FC<any> = () => {
};
return (
<div className="sidemenu__top">
<div data-testid="top-section-items" className="sidemenu__top">
<TopSectionItem link={searchLink} onClick={onOpenSearch} />
{mainLinks.map((link, index) => {
return <TopSectionItem link={link} key={`${link.id}-${index}`} />;
@@ -1,5 +1,5 @@
import React from 'react';
import { mount } from 'enzyme';
import { render, screen } from '@testing-library/react';
import TopSectionItem from './TopSectionItem';
import { MemoryRouter } from 'react-router-dom';
@@ -15,7 +15,7 @@ const setup = (propOverrides?: object) => {
propOverrides
);
return mount(
return render(
<MemoryRouter initialEntries={[{ pathname: '/', key: 'testKey' }]}>
<TopSectionItem {...props} />
</MemoryRouter>
@@ -24,7 +24,8 @@ const setup = (propOverrides?: object) => {
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
setup();
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByRole('menu')).toHaveTextContent('Hello');
});
});
@@ -1,7 +1,8 @@
import React, { FC } from 'react';
import SideMenuDropDown from './SideMenuDropDown';
import { Icon, Link } from '@grafana/ui';
import { Icon, Link, useStyles2 } from '@grafana/ui';
import { NavModelItem } from '@grafana/data';
import { css, cx } from '@emotion/css';
export interface Props {
link: NavModelItem;
@@ -9,6 +10,13 @@ export interface Props {
}
const TopSectionItem: FC<Props> = ({ link, onClick }) => {
const resetButtonStyles = useStyles2(
() =>
css`
background-color: transparent;
`
);
const linkContent = (
<span className="icon-circle sidemenu-icon">
{link.icon && <Icon name={link.icon as any} size="xl" />}
@@ -17,13 +25,20 @@ const TopSectionItem: FC<Props> = ({ link, onClick }) => {
);
const anchor = link.url ? (
<Link className="sidemenu-link" href={link.url} target={link.target} onClick={onClick}>
<Link
className="sidemenu-link"
href={link.url}
target={link.target}
aria-label={link.text}
onClick={onClick}
aria-haspopup="true"
>
{linkContent}
</Link>
) : (
<a className="sidemenu-link" onClick={onClick}>
<button className={cx(resetButtonStyles, 'sidemenu-link')} onClick={onClick} aria-label={link.text}>
{linkContent}
</a>
</button>
);
return (
<div className="sidemenu-item dropdown">
@@ -1,51 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="sidemenu__top"
>
<TopSectionItem
link={
Object {
"icon": "search",
"text": "Search",
}
}
onClick={[Function]}
/>
<TopSectionItem
key="3-0"
link={
Object {
"hideFromMenu": false,
"id": "3",
}
}
/>
</div>
`;
exports[`Render should render items 1`] = `
<div
className="sidemenu__top"
>
<TopSectionItem
link={
Object {
"icon": "search",
"text": "Search",
}
}
onClick={[Function]}
/>
<TopSectionItem
key="3-0"
link={
Object {
"hideFromMenu": false,
"id": "3",
}
}
/>
</div>
`;
@@ -1,151 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<MemoryRouter
initialEntries={
Array [
Object {
"key": "testKey",
"pathname": "/",
},
]
}
>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "testKey",
"pathname": "/",
"search": "",
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "testKey",
"pathname": "/",
"search": "",
},
"push": [Function],
"replace": [Function],
}
}
>
<TopSectionItem
link={
Object {
"icon": "cloud",
"text": "Hello",
"url": "/asd",
}
}
>
<div
className="sidemenu-item dropdown"
>
<Link
className="sidemenu-link"
href="/asd"
>
<Link
className="sidemenu-link"
to="/asd"
>
<LinkAnchor
className="sidemenu-link"
href="/asd"
navigate={[Function]}
>
<a
className="sidemenu-link"
href="/asd"
onClick={[Function]}
>
<span
className="icon-circle sidemenu-icon"
>
<Icon
name="cloud"
size="xl"
>
<div
className="css-1vzus6i-Icon"
>
<InlineSVG
cacheRequests={true}
className="css-sr6nr"
height={24}
src="public/img/iconsunicons/cloud.svg"
uniquifyIDs={false}
width={24}
/>
</div>
</Icon>
</span>
</a>
</LinkAnchor>
</Link>
</Link>
<SideMenuDropDown
link={
Object {
"icon": "cloud",
"text": "Hello",
"url": "/asd",
}
}
>
<ul
className="dropdown-menu dropdown-menu--sidemenu"
role="menu"
>
<li
className="side-menu-header"
>
<Link
className="side-menu-header-link"
href="/asd"
>
<Link
className="side-menu-header-link"
to="/asd"
>
<LinkAnchor
className="side-menu-header-link"
href="/asd"
navigate={[Function]}
>
<a
className="side-menu-header-link"
href="/asd"
onClick={[Function]}
>
<span
className="sidemenu-item-text"
>
Hello
</span>
</a>
</LinkAnchor>
</Link>
</Link>
</li>
</ul>
</SideMenuDropDown>
</div>
</TopSectionItem>
</Router>
</MemoryRouter>
`;
@@ -94,4 +94,64 @@ describe('DataFrame to annotations', () => {
],
]);
});
it('all valid key names should be included in the output result', async () => {
const frame = toDataFrame({
fields: [
{ name: 'time', values: [100] },
{ name: 'timeEnd', values: [200] },
{ name: 'title', values: ['title'] },
{ name: 'text', values: ['text'] },
{ name: 'tags', values: ['t1,t2,t3'] },
{ name: 'id', values: [1] },
{ name: 'userId', values: ['Admin'] },
{ name: 'login', values: ['admin'] },
{ name: 'email', values: ['admin@unknown.us'] },
{ name: 'prevState', values: ['normal'] },
{ name: 'newState', values: ['alerting'] },
{ name: 'data', values: [{ text: 'a', value: 'A' }] },
{ name: 'panelId', values: [4] },
],
});
const observable = getAnnotationsFromData([frame]);
await expect(observable).toEmitValues([
[
{
color: 'red',
data: { text: 'a', value: 'A' },
email: 'admin@unknown.us',
id: 1,
login: 'admin',
newState: 'alerting',
panelId: 4,
prevState: 'normal',
tags: ['t1', 't2', 't3'],
text: 'text',
time: 100,
timeEnd: 200,
title: 'title',
type: 'default',
userId: 'Admin',
},
],
]);
});
it('key names that are not valid should be excluded in the output result', async () => {
const frame = toDataFrame({
fields: [
{ name: 'time', values: [100] },
{ name: 'text', values: ['text'] },
{ name: 'someData', values: [{ value: 'bar' }] },
{ name: 'panelSource', values: ['100'] },
{ name: 'timeStart', values: [100] },
],
});
const observable = getAnnotationsFromData([frame]);
await expect(observable).toEmitValues([[{ color: 'red', text: 'text', time: 100, type: 'default' }]]);
});
});
@@ -122,6 +122,7 @@ const alertEventAndAnnotationFields: AnnotationFieldInfo[] = [
{ key: 'prevState' },
{ key: 'newState' },
{ key: 'data' as any },
{ key: 'panelId' },
];
export function getAnnotationsFromData(
@@ -239,17 +239,19 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
return <PanelEditorTableView width={width} height={height} panel={panel} dashboard={dashboard} />;
}
const panelSize = calculatePanelSize(uiState.mode, width, height, panel);
return (
<div className={styles.centeringContainer} style={{ width, height }}>
<div style={calculatePanelSize(uiState.mode, width, height, panel)} data-panelid={panel.editSourceId}>
<div style={panelSize} data-panelid={panel.editSourceId}>
<DashboardPanel
dashboard={dashboard}
panel={panel}
isEditing={true}
isViewing={false}
isInView={true}
width={width}
height={height}
width={panelSize.width}
height={panelSize.height}
/>
</div>
</div>
@@ -1,11 +1,10 @@
import { CSSProperties } from 'react';
import { omit } from 'lodash';
import { FieldConfigSource, PanelPlugin } from '@grafana/data';
import { PanelModel } from '../../state/PanelModel';
import { DisplayMode } from './types';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
export function calculatePanelSize(mode: DisplayMode, width: number, height: number, panel: PanelModel): CSSProperties {
export function calculatePanelSize(mode: DisplayMode, width: number, height: number, panel: PanelModel) {
if (mode === DisplayMode.Fill) {
return { width, height };
}
@@ -32,6 +32,7 @@ export class DashboardGrid extends PureComponent<Props, State> {
private panelMap: { [id: string]: PanelModel } = {};
private eventSubs = new Subscription();
private windowHeight = 1200;
private windowWidth = 1920;
private gridWidth = 0;
constructor(props: Props) {
@@ -152,6 +153,7 @@ export class DashboardGrid extends PureComponent<Props, State> {
// We assume here that if width change height might have changed as well
if (this.gridWidth !== gridWidth) {
this.windowHeight = window.innerHeight ?? 1000;
this.windowWidth = window.innerWidth;
this.gridWidth = gridWidth;
}
@@ -170,6 +172,7 @@ export class DashboardGrid extends PureComponent<Props, State> {
gridPos={panel.gridPos}
gridWidth={gridWidth}
windowHeight={this.windowHeight}
windowWidth={this.windowWidth}
isViewing={panel.isViewing}
>
{(width: number, height: number) => {
@@ -259,6 +262,7 @@ interface GrafanaGridItemProps extends Record<string, any> {
gridPos?: GridPos;
isViewing: string;
windowHeight: number;
windowWidth: number;
children: any;
}
@@ -270,15 +274,15 @@ const GrafanaGridItem = React.forwardRef<HTMLDivElement, GrafanaGridItemProps>((
let width = 100;
let height = 100;
const { gridWidth, gridPos, isViewing, windowHeight, ...divProps } = props;
const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, ...divProps } = props;
const style: CSSProperties = props.style ?? {};
if (isViewing) {
width = props.gridWidth!;
width = gridWidth!;
height = windowHeight * 0.85;
style.height = height;
style.width = '100%';
} else if (props.gridWidth! < theme.breakpoints.values.md) {
} else if (windowWidth < theme.breakpoints.values.md) {
width = props.gridWidth!;
height = props.gridPos!.h * (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN) - GRID_CELL_VMARGIN;
style.height = height;
@@ -428,7 +428,6 @@ export class PanelChrome extends Component<Props, State> {
const containerClassNames = classNames({
'panel-container': true,
'panel-container--absolute': true,
'panel-container--transparent': transparent,
'panel-container--no-title': this.hasOverlayHeader(),
[`panel-alert-state--${alertState}`]: alertState !== undefined,
@@ -192,7 +192,6 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
const containerClassNames = classNames({
'panel-container': true,
'panel-container--absolute': true,
'panel-container--transparent': transparent,
'panel-container--no-title': this.hasOverlayHeader(),
'panel-has-alert': panel.alert !== undefined,
@@ -86,7 +86,7 @@ export class ExplorePaneContainerUnconnected extends React.PureComponent<Props>
render() {
const exploreClass = this.props.split ? 'explore explore-split' : 'explore';
return (
<div className={exploreClass} ref={this.getRef} aria-label={selectors.pages.Explore.General.container}>
<div className={exploreClass} ref={this.getRef} data-testid={selectors.pages.Explore.General.container}>
{this.props.initialized && <Explore exploreId={this.props.exploreId} />}
</div>
);
@@ -45,8 +45,8 @@ export class LiveConnectionWarning extends PureComponent<Props, State> {
render() {
const { show } = this.state;
if (show) {
if (!contextSrv.isSignedIn || !config.liveEnabled) {
return null; // do not show the warning for anonymous users (and /login page etc)
if (!contextSrv.isSignedIn || !config.liveEnabled || contextSrv.user.orgRole === '') {
return null; // do not show the warning for anonymous users or ones with no org (and /login page etc)
}
return (
@@ -252,7 +252,7 @@ export const AzureCredentialsForm: FunctionComponent<Props> = (props: Props) =>
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Default Subscription</InlineFormLabel>
<div className="width-25">
<div className="width-30">
<Select
menuShouldPortal
value={
@@ -7,6 +7,7 @@ import {
updateDatasourcePluginResetOption,
updateDatasourcePluginSecureJsonDataOption,
} from '@grafana/data';
import { Alert } from '@grafana/ui';
import { MonitorConfig } from './MonitorConfig';
import { AnalyticsConfig } from './AnalyticsConfig';
import { getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
@@ -18,9 +19,16 @@ import { routeNames } from '../utils/common';
export type Props = DataSourcePluginOptionsEditorProps<AzureDataSourceJsonData, AzureDataSourceSecureJsonData>;
interface ErrorMessage {
title: string;
description: string;
details?: string;
}
export interface State {
unsaved: boolean;
appInsightsInitiallyConfigured: boolean;
error?: ErrorMessage;
}
export class ConfigEditor extends PureComponent<Props, State> {
@@ -60,12 +68,26 @@ export class ConfigEditor extends PureComponent<Props, State> {
await this.saveOptions();
const query = `?api-version=2019-03-01`;
const result = await getBackendSrv().datasourceRequest({
url: this.baseURL + query,
method: 'GET',
});
try {
const result = await getBackendSrv()
.fetch({
url: this.baseURL + query,
method: 'GET',
})
.toPromise();
return ResponseParser.parseSubscriptionsForSelect(result);
this.setState({ error: undefined });
return ResponseParser.parseSubscriptionsForSelect(result);
} catch (err) {
this.setState({
error: {
title: 'Error requesting subscriptions',
description: 'Could not request subscriptions from Azure. Check your credentials and try again.',
details: err?.data?.message,
},
});
return Promise.resolve([]);
}
};
// TODO: Used only by InsightsConfig
@@ -89,6 +111,7 @@ export class ConfigEditor extends PureComponent<Props, State> {
render() {
const { options } = this.props;
const { error } = this.state;
return (
<>
@@ -102,6 +125,13 @@ export class ConfigEditor extends PureComponent<Props, State> {
onResetOptionKey={this.resetSecureKey}
/>
)}
{error && (
<Alert severity="error" title={error.title}>
<p>{error.description}</p>
{error.details && <details style={{ whiteSpace: 'pre-wrap' }}>{error.details}</details>}
</Alert>
)}
</>
);
}
@@ -69,10 +69,15 @@ export const useSubscriptions: DataHook = (query, datasource, onChange, setError
);
useEffect(() => {
if (!subscription && defaultSubscription && hasOption(subscriptionOptions, defaultSubscription)) {
onChange(setSubscriptionID(query, defaultSubscription));
} else if ((!subscription && subscriptionOptions.length) || subscriptionOptions.length === 1) {
onChange(setSubscriptionID(query, subscriptionOptions[0].value));
// Return early if subscriptions havent loaded, or if the query already has a subscription
if (!subscriptionOptions.length || (subscription && hasOption(subscriptionOptions, subscription))) {
return;
}
const defaultSub = defaultSubscription || subscriptionOptions[0].value;
if (!subscription && defaultSub && hasOption(subscriptionOptions, defaultSub)) {
onChange(setSubscriptionID(query, defaultSub));
}
}, [subscriptionOptions, query, subscription, defaultSubscription, onChange]);
@@ -18,7 +18,7 @@ import ApplicationInsightsEditor from '../ApplicationInsightsEditor';
import InsightsAnalyticsEditor from '../InsightsAnalyticsEditor';
import { Space } from '../Space';
import { debounce } from 'lodash';
import useDefaultQuery from './useDefaultQuery';
import usePreparedQuery from './usePreparedQuery';
export type AzureMonitorQueryEditorProps = QueryEditorProps<
AzureMonitorDatasource,
@@ -43,7 +43,7 @@ const QueryEditor: React.FC<AzureMonitorQueryEditorProps> = ({
[onChange, onRunQuery]
);
const query = useDefaultQuery(baseQuery, onQueryChange);
const query = usePreparedQuery(baseQuery, onQueryChange);
const subscriptionId = query.subscription || datasource.azureMonitorDatasource.defaultSubscriptionId;
const variableOptionGroup = {
@@ -1,34 +0,0 @@
import { useEffect, useMemo } from 'react';
import { AzureMonitorQuery, AzureQueryType } from '../../types';
const DEFAULT_QUERY_TYPE = AzureQueryType.AzureMonitor;
const createQueryWithDefaults = (query: AzureMonitorQuery) => {
// A quick and easy way to set just the default query type. If we want to set any other defaults,
// we might want to look into something more robust
if (!query.queryType) {
return {
...query,
queryType: query.queryType ?? DEFAULT_QUERY_TYPE,
};
}
return query;
};
/**
* Returns queries with some defaults, and calls onChange function to notify if it changes
*/
const useDefaultQuery = (query: AzureMonitorQuery, onChangeQuery: (newQuery: AzureMonitorQuery) => void) => {
const queryWithDefaults = useMemo(() => createQueryWithDefaults(query), [query]);
useEffect(() => {
if (queryWithDefaults !== query) {
onChangeQuery(queryWithDefaults);
}
}, [queryWithDefaults, query, onChangeQuery]);
return queryWithDefaults;
};
export default useDefaultQuery;
@@ -0,0 +1,36 @@
import { useEffect, useMemo } from 'react';
import { defaults } from 'lodash';
import { AzureMonitorQuery, AzureQueryType } from '../../types';
import deepEqual from 'fast-deep-equal';
import migrateQuery from '../../utils/migrateQuery';
const DEFAULT_QUERY = {
queryType: AzureQueryType.AzureMonitor,
};
const prepareQuery = (query: AzureMonitorQuery) => {
// Note: _.defaults does not apply default values deeply.
const withDefaults = defaults({}, query, DEFAULT_QUERY);
const migratedQuery = migrateQuery(withDefaults);
// If we didn't make any changes to the object, then return the original object to keep the
// identity the same, and not trigger any other useEffects or anything.
return deepEqual(migratedQuery, query) ? query : migratedQuery;
};
/**
* Returns queries with some defaults + migrations, and calls onChange function to notify if it changes
*/
const usePreparedQuery = (query: AzureMonitorQuery, onChangeQuery: (newQuery: AzureMonitorQuery) => void) => {
const preparedQuery = useMemo(() => prepareQuery(query), [query]);
useEffect(() => {
if (preparedQuery !== query) {
onChangeQuery(preparedQuery);
}
}, [preparedQuery, query, onChangeQuery]);
return preparedQuery;
};
export default usePreparedQuery;
@@ -160,7 +160,7 @@ exports[`Render should disable azure monitor secret input 1`] = `
Default Subscription
</FormLabel>
<div
className="width-25"
className="width-30"
>
<Select
allowCustomValue={false}
@@ -365,7 +365,7 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = `
Default Subscription
</FormLabel>
<div
className="width-25"
className="width-30"
>
<Select
allowCustomValue={false}
@@ -570,7 +570,7 @@ exports[`Render should render component 1`] = `
Default Subscription
</FormLabel>
<div
className="width-25"
className="width-30"
>
<Select
allowCustomValue={false}
@@ -3,13 +3,7 @@ import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource';
import AppInsightsDatasource from './app_insights/app_insights_datasource';
import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource';
import ResourcePickerData from './resourcePicker/resourcePickerData';
import {
AzureDataSourceJsonData,
AzureMonitorQuery,
AzureQueryType,
DatasourceValidationResult,
InsightsAnalyticsQuery,
} from './types';
import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType, DatasourceValidationResult } from './types';
import {
DataFrame,
DataQueryRequest,
@@ -22,7 +16,7 @@ import {
import { forkJoin, Observable, of } from 'rxjs';
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import InsightsAnalyticsDatasource from './insights_analytics/insights_analytics_datasource';
import { migrateMetricsDimensionFilters } from './query_ctrl';
import { datasourceMigrations } from './utils/migrateQuery';
import { map } from 'rxjs/operators';
import AzureResourceGraphDatasource from './azure_resource_graph/azure_resource_graph_datasource';
import { getAzureCloud } from './credentials';
@@ -82,9 +76,9 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
query(options: DataQueryRequest<AzureMonitorQuery>): Observable<DataQueryResponse> {
const byType = new Map<AzureQueryType, DataQueryRequest<AzureMonitorQuery>>();
for (const target of options.targets) {
// Migrate old query structure
migrateQuery(target);
for (const baseTarget of options.targets) {
// Migrate old query structures
const target = datasourceMigrations(baseTarget);
// Skip hidden or invalid queries or ones without properties
if (!target.queryType || target.hide || !hasQueryForType(target)) {
@@ -298,23 +292,6 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
}
}
function migrateQuery(target: AzureMonitorQuery) {
if (target.queryType === AzureQueryType.ApplicationInsights) {
if ((target.appInsights as any).rawQuery) {
target.queryType = AzureQueryType.InsightsAnalytics;
target.insightsAnalytics = (target.appInsights as unknown) as InsightsAnalyticsQuery;
delete target.appInsights;
}
}
if (!target.queryType) {
target.queryType = AzureQueryType.AzureMonitor;
}
if (target.queryType === AzureQueryType.AzureMonitor && target.azureMonitor) {
migrateMetricsDimensionFilters(target.azureMonitor);
}
}
function hasQueryForType(query: AzureMonitorQuery): boolean {
switch (query.queryType) {
case AzureQueryType.AzureMonitor:
@@ -36,8 +36,11 @@ export interface AzureMonitorQuery extends DataQuery {
*/
export interface AzureMetricQuery {
resourceGroup?: string;
resourceName?: string;
/** Resource type */
metricDefinition?: string;
resourceName?: string;
metricNamespace?: string;
metricName?: string;
timeGrain?: string;
@@ -51,6 +54,12 @@ export interface AzureMetricQuery {
/** @deprecated Remove this once angular is removed */
allowedTimeGrainsMs?: number[];
/** @deprecated This property was migrated to dimensionFilters and should only be accessed in the migration */
dimension?: string;
/** @deprecated This property was migrated to dimensionFilters and should only be accessed in the migration */
dimensionFilter?: string;
}
/**
@@ -86,6 +95,9 @@ export interface ApplicationInsightsQuery {
dimension?: string[]; // Was string before 7.1
dimensionFilter?: string;
alias?: string;
/** @deprecated Migrated to Insights Analytics query */
rawQuery?: string;
}
/**
@@ -0,0 +1,40 @@
import { AzureMonitorQuery, AzureQueryType } from '../types';
import migrateQuery from './migrateQuery';
const modernMetricsQuery: AzureMonitorQuery = {
appInsights: { dimension: [], metricName: 'select', timeGrain: 'auto' },
azureLogAnalytics: {
query:
'//change this example to create your own time series query\n<table name> //the table to query (e.g. Usage, Heartbeat, Perf)\n| where $__timeFilter(TimeGenerated) //this is a macro used to show the full chart’s time range, choose the datetime column here\n| summarize count() by <group by column>, bin(TimeGenerated, $__interval) //change “group by column” to a column in your table, such as “Computer”. The $__interval macro is used to auto-select the time grain. Can also use 1h, 5m etc.\n| order by TimeGenerated asc',
resultFormat: 'time_series',
workspace: 'e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2',
},
azureMonitor: {
aggregation: 'Average',
alias: '{{ dimensionvalue }}',
allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000],
dimensionFilters: [{ dimension: 'dependency/success', filter: '', operator: 'eq' }],
metricDefinition: 'microsoft.insights/components',
metricName: 'dependencies/duration',
metricNamespace: 'microsoft.insights/components',
resourceGroup: 'cloud-datasources',
resourceName: 'AppInsightsTestData',
timeGrain: 'PT5M',
top: '10',
},
azureResourceGraph: { resultFormat: 'table' },
insightsAnalytics: { query: '', resultFormat: 'time_series' },
queryType: AzureQueryType.AzureMonitor,
refId: 'A',
subscription: '44693801-6ee6-49de-9b2d-9106972f9572',
subscriptions: ['44693801-6ee6-49de-9b2d-9106972f9572'],
};
describe('AzureMonitor: migrateQuery', () => {
it('modern queries should not change', () => {
const result = migrateQuery(modernMetricsQuery);
// MUST use .toBe because we want to assert that the identity of unmigrated queries remains the same
expect(modernMetricsQuery).toBe(result);
});
});
@@ -0,0 +1,165 @@
import { AzureMonitorQuery, AzureQueryType } from '../types';
import TimegrainConverter from '../time_grain_converter';
import {
appendDimensionFilter,
setTimeGrain as setMetricsTimeGrain,
} from '../components/MetricsQueryEditor/setQueryValue';
import { setKustoQuery } from '../components/LogsQueryEditor/setQueryValue';
const OLD_DEFAULT_DROPDOWN_VALUE = 'select';
export default function migrateQuery(query: AzureMonitorQuery): AzureMonitorQuery {
let workingQuery = query;
// The old angular controller also had a `migrateApplicationInsightsKeys` migraiton that
// migrated old properties to other properties that still do not appear to be used anymore, so
// we decided to not include that migration anymore
// See https://github.com/grafana/grafana/blob/a6a09add/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts#L269-L288
workingQuery = migrateTimeGrains(workingQuery);
workingQuery = migrateLogAnalyticsToFromTimes(workingQuery);
workingQuery = migrateToDefaultNamespace(workingQuery);
workingQuery = migrateApplicationInsightsDimensions(workingQuery);
workingQuery = migrateMetricsDimensionFilters(workingQuery);
return workingQuery;
}
function migrateTimeGrains(query: AzureMonitorQuery): AzureMonitorQuery {
let workingQuery = query;
if (workingQuery.azureMonitor?.timeGrainUnit && workingQuery.azureMonitor.timeGrain !== 'auto') {
const newTimeGrain = TimegrainConverter.createISO8601Duration(
workingQuery.azureMonitor.timeGrain ?? 'auto',
workingQuery.azureMonitor.timeGrainUnit
);
workingQuery = setMetricsTimeGrain(workingQuery, newTimeGrain);
delete workingQuery.azureMonitor?.timeGrainUnit;
}
if (workingQuery.appInsights?.timeGrainUnit && workingQuery.appInsights.timeGrain !== 'auto') {
const appInsights = {
...workingQuery.appInsights,
};
if (workingQuery.appInsights.timeGrainCount) {
appInsights.timeGrain = TimegrainConverter.createISO8601Duration(
workingQuery.appInsights.timeGrainCount,
workingQuery.appInsights.timeGrainUnit
);
} else {
appInsights.timeGrainCount = workingQuery.appInsights.timeGrain;
if (workingQuery.appInsights.timeGrain) {
appInsights.timeGrain = TimegrainConverter.createISO8601Duration(
workingQuery.appInsights.timeGrain,
workingQuery.appInsights.timeGrainUnit
);
}
}
workingQuery = {
...workingQuery,
appInsights: appInsights,
};
}
return workingQuery;
}
function migrateLogAnalyticsToFromTimes(query: AzureMonitorQuery): AzureMonitorQuery {
let workingQuery = query;
if (workingQuery.azureLogAnalytics?.query?.match(/\$__from\s/gi)) {
workingQuery = setKustoQuery(
workingQuery,
workingQuery.azureLogAnalytics.query.replace(/\$__from\s/gi, '$__timeFrom() ')
);
}
if (workingQuery.azureLogAnalytics?.query?.match(/\$__to\s/gi)) {
workingQuery = setKustoQuery(
workingQuery,
workingQuery.azureLogAnalytics.query.replace(/\$__to\s/gi, '$__timeTo() ')
);
}
return workingQuery;
}
function migrateToDefaultNamespace(query: AzureMonitorQuery): AzureMonitorQuery {
const haveMetricNamespace =
query.azureMonitor?.metricNamespace && query.azureMonitor.metricNamespace !== OLD_DEFAULT_DROPDOWN_VALUE;
if (!haveMetricNamespace && query.azureMonitor?.metricDefinition) {
return {
...query,
azureMonitor: {
...query.azureMonitor,
metricNamespace: query.azureMonitor.metricDefinition,
},
};
}
return query;
}
function migrateApplicationInsightsDimensions(query: AzureMonitorQuery): AzureMonitorQuery {
const dimension = query?.appInsights?.dimension as unknown;
if (dimension && typeof dimension === 'string') {
return {
...query,
appInsights: {
...query.appInsights,
dimension: [dimension],
},
};
}
return query;
}
// Exported because its also used directly in the datasource.ts for some reason
function migrateMetricsDimensionFilters(query: AzureMonitorQuery): AzureMonitorQuery {
let workingQuery = query;
const oldDimension = workingQuery.azureMonitor?.dimension;
if (oldDimension && oldDimension !== 'None') {
workingQuery = appendDimensionFilter(workingQuery, oldDimension, 'eq', workingQuery.azureMonitor?.dimensionFilter);
}
return workingQuery;
}
// datasource.ts also contains some migrations, which have been moved to here. Unsure whether
// they should also do all the other migrations...
export function datasourceMigrations(query: AzureMonitorQuery): AzureMonitorQuery {
let workingQuery = query;
if (workingQuery.queryType === AzureQueryType.ApplicationInsights && workingQuery.appInsights?.rawQuery) {
workingQuery = {
...workingQuery,
queryType: AzureQueryType.InsightsAnalytics,
appInsights: undefined,
insightsAnalytics: {
query: workingQuery.appInsights.rawQuery,
resultFormat: 'time_series',
},
};
}
if (!workingQuery.queryType) {
workingQuery = {
...workingQuery,
queryType: AzureQueryType.AzureMonitor,
};
}
if (workingQuery.queryType === AzureQueryType.AzureMonitor && workingQuery.azureMonitor) {
workingQuery = migrateMetricsDimensionFilters(workingQuery);
}
return workingQuery;
}
@@ -36,6 +36,7 @@ export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEdito
<LokiOptionFields
queryType={queryWithRefId.instant ? 'instant' : 'range'}
lineLimitValue={queryWithRefId?.maxLines?.toString() || ''}
resolution={queryWithRefId.resolution || 1}
query={queryWithRefId}
onRunQuery={() => {}}
onChange={onChange}
@@ -27,6 +27,7 @@ export function LokiExploreQueryEditor(props: Props) {
<LokiOptionFields
queryType={query.instant ? 'instant' : 'range'}
lineLimitValue={query?.maxLines?.toString() || ''}
resolution={query.resolution || 1}
query={query}
onRunQuery={onRunQuery}
onChange={onChange}
@@ -1,14 +1,16 @@
// Libraries
import React, { memo } from 'react';
import { css, cx } from '@emotion/css';
import { LokiQuery } from '../types';
import { SelectableValue } from '@grafana/data';
import { map } from 'lodash';
// Types
import { InlineFormLabel, RadioButtonGroup, InlineField, Input } from '@grafana/ui';
import { InlineFormLabel, RadioButtonGroup, InlineField, Input, Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { LokiQuery } from '../types';
export interface LokiOptionFieldsProps {
lineLimitValue: string;
resolution: number;
queryType: LokiQueryType;
query: LokiQuery;
onChange: (value: LokiQuery) => void;
@@ -27,8 +29,20 @@ const queryTypeOptions: Array<SelectableValue<LokiQueryType>> = [
},
];
export const DEFAULT_RESOLUTION: SelectableValue<number> = {
value: 1,
label: '1/1',
};
const RESOLUTION_OPTIONS: Array<SelectableValue<number>> = [DEFAULT_RESOLUTION].concat(
map([2, 3, 4, 5, 10], (value: number) => ({
value,
label: '1/' + value,
}))
);
export function LokiOptionFields(props: LokiOptionFieldsProps) {
const { lineLimitValue, queryType, query, onRunQuery, runOnBlur, onChange } = props;
const { lineLimitValue, resolution, queryType, query, onRunQuery, runOnBlur, onChange } = props;
function onChangeQueryLimit(value: string) {
const nextQuery = { ...query, maxLines: preprocessMaxLines(value) };
@@ -71,6 +85,11 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) {
}
}
function onResolutionChange(option: SelectableValue<number>) {
const nextQuery = { ...query, resolution: option.value };
onChange(nextQuery);
}
return (
<div aria-label="Loki extra field" className="gf-form-inline">
{/*Query type field*/}
@@ -108,7 +127,7 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) {
)}
aria-label="Line limit field"
>
<InlineField label="Line limit">
<InlineField label="Line limit" tooltip={'Upper limit for number of log lines returned by query.'}>
<Input
className="width-4"
placeholder="auto"
@@ -124,6 +143,14 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) {
}}
/>
</InlineField>
<InlineField
label="Resolution"
tooltip={
'Resolution 1/1 sets step parameter of Loki metrics range queries such that each pixel corresponds to one data point. For better performance, lower resolutions can be picked. 1/2 only retrieves a data point for every other pixel, and 1/10 retrieves one data point per 10 pixels.'
}
>
<Select isSearchable={false} onChange={onResolutionChange} options={RESOLUTION_OPTIONS} value={resolution} />
</InlineField>
</div>
</div>
);
@@ -53,6 +53,7 @@ export function LokiQueryEditor(props: LokiQueryEditorProps) {
<LokiOptionFields
queryType={query.instant ? 'instant' : 'range'}
lineLimitValue={query?.maxLines?.toString() || ''}
resolution={query?.resolution || 1}
query={query}
onRunQuery={onRunQuery}
onChange={onChange}
@@ -15,6 +15,7 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
}
}
queryType="range"
resolution={1}
/>
}
data={
@@ -16,6 +16,7 @@ exports[`Render LokiQueryEditor with legend should render 1`] = `
}
}
queryType="range"
resolution={1}
runOnBlur={true}
/>
<div
@@ -81,6 +82,7 @@ exports[`Render LokiQueryEditor with legend should update timerange 1`] = `
}
}
queryType="range"
resolution={1}
runOnBlur={true}
/>
<div
@@ -112,7 +112,7 @@ describe('LokiDatasource', () => {
const req = ds.createRangeQuery(target, options as any, 1000);
expect(req.start).toBeDefined();
expect(req.end).toBeDefined();
expect(adjustIntervalSpy).toHaveBeenCalledWith(1000, expect.anything());
expect(adjustIntervalSpy).toHaveBeenCalledWith(1000, 1, expect.anything());
});
it('should use provided intervalMs', () => {
@@ -127,7 +127,7 @@ describe('LokiDatasource', () => {
const req = ds.createRangeQuery(target, options as any, 1000);
expect(req.start).toBeDefined();
expect(req.end).toBeDefined();
expect(adjustIntervalSpy).toHaveBeenCalledWith(2000, expect.anything());
expect(adjustIntervalSpy).toHaveBeenCalledWith(2000, 1, expect.anything());
});
it('should set the minimal step to 1ms', () => {
@@ -142,7 +142,7 @@ describe('LokiDatasource', () => {
const req = ds.createRangeQuery(target, options as any, 1000);
expect(req.start).toBeDefined();
expect(req.end).toBeDefined();
expect(adjustIntervalSpy).toHaveBeenCalledWith(0.0005, expect.anything());
expect(adjustIntervalSpy).toHaveBeenCalledWith(0.0005, expect.anything(), 1000);
// Step is in seconds (1 ms === 0.001 s)
expect(req.step).toEqual(0.001);
});
@@ -399,7 +399,7 @@ describe('LokiDatasource', () => {
describe('__range, __range_s and __range_ms variables', () => {
const options = {
targets: [{ expr: 'rate(process_cpu_seconds_total[$__range])', refId: 'A' }],
targets: [{ expr: 'rate(process_cpu_seconds_total[$__range])', refId: 'A', stepInterval: '2s' }],
range: {
from: rawRange.from,
to: rawRange.to,
@@ -581,7 +581,7 @@ describe('LokiDatasource', () => {
status: 'success',
},
} as unknown) as FetchResponse;
const { promise } = getTestContext(response);
const { promise } = getTestContext(response, { stepInterval: '15s' });
const res = await promise;
@@ -613,7 +613,7 @@ describe('LokiDatasource', () => {
} as unknown) as FetchResponse;
describe('When tagKeys is set', () => {
it('should only include selected labels', async () => {
const { promise } = getTestContext(response, { tagKeys: 'label2,label3' });
const { promise } = getTestContext(response, { tagKeys: 'label2,label3', stepInterval: '15s' });
const res = await promise;
@@ -624,7 +624,7 @@ describe('LokiDatasource', () => {
});
describe('When textFormat is set', () => {
it('should fromat the text accordingly', async () => {
const { promise } = getTestContext(response, { textFormat: 'hello {{label2}}' });
const { promise } = getTestContext(response, { textFormat: 'hello {{label2}}', stepInterval: '15s' });
const res = await promise;
@@ -634,7 +634,7 @@ describe('LokiDatasource', () => {
});
describe('When titleFormat is set', () => {
it('should fromat the title accordingly', async () => {
const { promise } = getTestContext(response, { titleFormat: 'Title {{label2}}' });
const { promise } = getTestContext(response, { titleFormat: 'Title {{label2}}', stepInterval: '15s' });
const res = await promise;
@@ -781,6 +781,26 @@ describe('LokiDatasource', () => {
});
});
});
describe('adjustInterval', () => {
const dynamicInterval = 15;
const range = 1642;
const resolution = 1;
const ds = createLokiDSForTests();
it('should return the interval as a factor of dynamicInterval and resolution', () => {
let interval = ds.adjustInterval(dynamicInterval, resolution, range);
expect(interval).toBe(resolution * dynamicInterval);
});
it('should not return a value less than the safe interval', () => {
let safeInterval = range / 11000;
if (safeInterval > 1) {
safeInterval = Math.ceil(safeInterval);
}
const unsafeInterval = safeInterval - 0.01;
let interval = ds.adjustInterval(unsafeInterval, resolution, range);
expect(interval).toBeGreaterThanOrEqual(safeInterval);
});
});
});
function createLokiDSForTests(
@@ -51,6 +51,7 @@ import LanguageProvider from './language_provider';
import { serializeParams } from '../../../core/utils/fetch';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import syntax from './syntax';
import { DEFAULT_RESOLUTION } from './components/LokiOptionFields';
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
export const DEFAULT_MAX_LINES = 1000;
@@ -186,8 +187,11 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
const startNs = this.getTime(options.range.from, false);
const endNs = this.getTime(options.range.to, true);
const rangeMs = Math.ceil((endNs - startNs) / 1e6);
const resolution = target.resolution || (DEFAULT_RESOLUTION.value as number);
const adjustedInterval =
this.adjustInterval((options as DataQueryRequest<LokiQuery>).intervalMs || 1000, rangeMs) / 1000;
this.adjustInterval((options as DataQueryRequest<LokiQuery>).intervalMs || 1000, resolution, rangeMs) / 1000;
// We want to ceil to 3 decimal places
const step = Math.ceil(adjustedInterval * 1000) / 1000;
@@ -558,14 +562,28 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
}
async annotationQuery(options: any): Promise<AnnotationEvent[]> {
const { expr, maxLines, instant, tagKeys = '', titleFormat = '', textFormat = '' } = options.annotation;
const {
expr,
maxLines,
instant,
stepInterval,
tagKeys = '',
titleFormat = '',
textFormat = '',
} = options.annotation;
if (!expr) {
return [];
}
const interpolatedExpr = this.templateSrv.replace(expr, {}, this.interpolateQueryExpr);
const query = { refId: `annotation-${options.annotation.name}`, expr: interpolatedExpr, maxLines, instant };
const query = {
refId: `annotation-${options.annotation.name}`,
expr: interpolatedExpr,
maxLines,
instant,
stepInterval,
};
const { data } = instant
? await this.runInstantQuery(query, options as any).toPromise()
: await this.runRangeQuery(query, options as any).toPromise();
@@ -634,14 +652,16 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return error;
}
adjustInterval(interval: number, range: number) {
adjustInterval(dynamicInterval: number, resolution: number, range: number) {
// Loki will drop queries that might return more than 11000 data points.
// Calibrate interval if it is too small.
if (interval !== 0 && range / interval > 11000) {
interval = Math.ceil(range / 11000);
let safeInterval = range / 11000;
if (safeInterval > 1) {
safeInterval = Math.ceil(safeInterval);
}
// The min interval is set to 1ms
return Math.max(interval, 1);
let adjustedInterval = Math.max(resolution * dynamicInterval, safeInterval);
return adjustedInterval;
}
addAdHocFilters(queryExpr: string) {
@@ -30,6 +30,7 @@ export interface LokiQuery extends DataQuery {
legendFormat?: string;
valueWithRefId?: boolean;
maxLines?: number;
resolution?: number;
range?: boolean;
instant?: boolean;
}
@@ -3,16 +3,20 @@ import React, { memo } from 'react';
import { css, cx } from '@emotion/css';
// Types
import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui';
import { PromQuery } from '../types';
import { InlineFormLabel, RadioButtonGroup, Select } from '@grafana/ui';
import { PromQuery, StepMode } from '../types';
import { PromExemplarField } from './PromExemplarField';
import { PrometheusDatasource } from '../datasource';
import { STEP_MODES } from './PromQueryEditor';
import { SelectableValue } from '@grafana/data';
export interface PromExploreExtraFieldProps {
queryType: string;
stepValue: string;
stepMode: StepMode;
query: PromQuery;
onStepChange: (e: React.SyntheticEvent<HTMLInputElement>) => void;
onStepModeChange: (option: SelectableValue<StepMode>) => void;
onStepIntervalChange: (e: React.SyntheticEvent<HTMLInputElement>) => void;
onKeyDownFunc: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onQueryTypeChange: (value: string) => void;
onChange: (value: PromQuery) => void;
@@ -20,7 +24,18 @@ export interface PromExploreExtraFieldProps {
}
export const PromExploreExtraField: React.FC<PromExploreExtraFieldProps> = memo(
({ queryType, stepValue, query, onChange, onStepChange, onQueryTypeChange, onKeyDownFunc, datasource }) => {
({
queryType,
stepValue,
stepMode,
query,
onChange,
onStepModeChange,
onStepIntervalChange,
onQueryTypeChange,
onKeyDownFunc,
datasource,
}) => {
const rangeOptions = [
{ value: 'range', label: 'Range', description: 'Run query over a range of time.' },
{
@@ -67,11 +82,20 @@ export const PromExploreExtraField: React.FC<PromExploreExtraFieldProps> = memo(
>
Step
</InlineFormLabel>
<Select
menuShouldPortal
className={'select-container'}
width={16}
isSearchable={false}
options={STEP_MODES}
onChange={onStepModeChange}
value={stepMode}
/>
<input
type={'text'}
className="gf-form-input width-4"
placeholder={'auto'}
onChange={onStepChange}
onChange={onStepIntervalChange}
onKeyDown={onKeyDownFunc}
value={stepValue}
/>
@@ -1,10 +1,10 @@
import React, { memo, FC, useEffect } from 'react';
// Types
import { ExploreQueryFieldProps } from '@grafana/data';
import { ExploreQueryFieldProps, SelectableValue } from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import { PromQuery, PromOptions } from '../types';
import { PromQuery, PromOptions, StepMode } from '../types';
import PromQueryField from './PromQueryField';
import { PromExploreExtraField } from './PromExploreExtraField';
@@ -26,7 +26,19 @@ export const PromExploreQueryEditor: FC<Props> = (props: Props) => {
onChange(nextQuery);
}
function onStepChange(e: React.SyntheticEvent<HTMLInputElement>) {
function onChangeStepMode(mode: StepMode) {
const { query, onChange } = props;
const nextQuery = { ...query, stepMode: mode };
onChange(nextQuery);
}
function onStepModeChange(option: SelectableValue<StepMode>) {
if (option.value) {
onChangeStepMode(option.value);
}
}
function onStepIntervalChange(e: React.SyntheticEvent<HTMLInputElement>) {
if (e.currentTarget.value !== query.interval) {
onChangeQueryStep(e.currentTarget.value);
}
@@ -66,8 +78,10 @@ export const PromExploreQueryEditor: FC<Props> = (props: Props) => {
// Select "both" as default option when Explore is opened. In legacy requests, range and instant can be undefined. In this case, we want to run queries with "both".
queryType={query.range === query.instant ? 'both' : query.instant ? 'instant' : 'range'}
stepValue={query.interval || ''}
stepMode={query.stepMode || 'min'}
onQueryTypeChange={onQueryTypeChange}
onStepChange={onStepChange}
onStepModeChange={onStepModeChange}
onStepIntervalChange={onStepIntervalChange}
onKeyDownFunc={onReturnKeyDown}
query={query}
onChange={onChange}
@@ -29,7 +29,7 @@ export const DEFAULT_STEP_MODE: SelectableValue<StepMode> = {
label: 'Minimum',
};
const STEP_MODES: Array<SelectableValue<StepMode>> = [
export const STEP_MODES: Array<SelectableValue<StepMode>> = [
DEFAULT_STEP_MODE,
{
value: 'max',
@@ -16,7 +16,8 @@ exports[`PromExploreQueryEditor should render component 1`] = `
onChange={[MockFunction]}
onKeyDownFunc={[Function]}
onQueryTypeChange={[Function]}
onStepChange={[Function]}
onStepIntervalChange={[Function]}
onStepModeChange={[Function]}
query={
Object {
"expr": "",
@@ -25,6 +26,7 @@ exports[`PromExploreQueryEditor should render component 1`] = `
}
}
queryType="both"
stepMode="min"
stepValue="1s"
/>
}
@@ -2,6 +2,8 @@ import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { DataSourceHttpSettings } from '@grafana/ui';
import { TraceToLogsSettings } from 'app/core/components/TraceToLogsSettings';
import React from 'react';
import { ServiceMapSettings } from './ServiceMapSettings';
import { config } from '@grafana/runtime';
export type Props = DataSourcePluginOptionsEditorProps;
@@ -15,7 +17,12 @@ export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => {
onChange={onOptionsChange}
/>
<TraceToLogsSettings options={options} onOptionsChange={onOptionsChange} />
<div className="gf-form-group">
<TraceToLogsSettings options={options} onOptionsChange={onOptionsChange} />
</div>
{config.featureToggles.tempoServiceGraph && (
<ServiceMapSettings options={options} onOptionsChange={onOptionsChange} />
)}
</>
);
};
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { DataQuery, DataSourceApi, ExploreQueryFieldProps } from '@grafana/data';
import { DataSourceApi, ExploreQueryFieldProps, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { getDataSourceSrv } from '@grafana/runtime';
import { config, getDataSourceSrv } from '@grafana/runtime';
import {
FileDropzone,
InlineField,
@@ -16,16 +16,27 @@ import { TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings';
import React from 'react';
import { LokiQueryField } from '../loki/components/LokiQueryField';
import { TempoDatasource, TempoQuery, TempoQueryType } from './datasource';
import LokiDatasource from '../loki/datasource';
import { LokiQuery } from '../loki/types';
import { PrometheusDatasource } from '../prometheus/datasource';
import useAsync from 'react-use/lib/useAsync';
interface Props extends ExploreQueryFieldProps<TempoDatasource, TempoQuery>, Themeable2 {}
const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceId';
interface State {
linkedDatasource?: DataSourceApi;
linkedDatasourceUid?: string;
linkedDatasource?: LokiDatasource;
serviceMapDatasourceUid?: string;
serviceMapDatasource?: PrometheusDatasource;
}
class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
state = {
linkedDatasourceUid: undefined,
linkedDatasource: undefined,
serviceMapDatasourceUid: undefined,
serviceMapDatasource: undefined,
};
constructor(props: Props) {
@@ -37,16 +48,21 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
// Find query field from linked datasource
const tracesToLogsOptions: TraceToLogsOptions = datasource.tracesToLogs || {};
const linkedDatasourceUid = tracesToLogsOptions.datasourceUid;
if (linkedDatasourceUid) {
const dsSrv = getDataSourceSrv();
const linkedDatasource = await dsSrv.get(linkedDatasourceUid);
this.setState({
linkedDatasource,
});
}
const serviceMapDsUid = datasource.serviceMap?.datasourceUid;
// Check status of linked data sources so we can show warnings if needed.
const [logsDs, serviceMapDs] = await Promise.all([getDS(linkedDatasourceUid), getDS(serviceMapDsUid)]);
this.setState({
linkedDatasourceUid: linkedDatasourceUid,
linkedDatasource: logsDs as LokiDatasource,
serviceMapDatasourceUid: serviceMapDsUid,
serviceMapDatasource: serviceMapDs as PrometheusDatasource,
});
}
onChangeLinkedQuery = (value: DataQuery) => {
onChangeLinkedQuery = (value: LokiQuery) => {
const { query, onChange } = this.props;
onChange({
...query,
@@ -59,19 +75,28 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
};
render() {
const { query, onChange } = this.props;
const { linkedDatasource } = this.state;
const { query, onChange, datasource } = this.props;
// Find query field from linked datasource
const tracesToLogsOptions: TraceToLogsOptions = datasource.tracesToLogs || {};
const logsDatasourceUid = tracesToLogsOptions.datasourceUid;
const graphDatasourceUid = datasource.serviceMap?.datasourceUid;
const queryTypeOptions: Array<SelectableValue<TempoQueryType>> = [
{ value: 'search', label: 'Search' },
{ value: 'traceId', label: 'TraceID' },
{ value: 'upload', label: 'JSON file' },
];
if (config.featureToggles.tempoServiceGraph) {
queryTypeOptions.push({ value: 'serviceMap', label: 'Service Map' });
}
return (
<>
<InlineFieldRow>
<InlineField label="Query type">
<RadioButtonGroup<TempoQueryType>
options={[
{ value: 'search', label: 'Search' },
{ value: 'traceId', label: 'TraceID' },
{ value: 'upload', label: 'JSON file' },
]}
options={queryTypeOptions}
value={query.queryType || DEFAULT_QUERY_TYPE}
onChange={(v) =>
onChange({
@@ -83,23 +108,13 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
/>
</InlineField>
</InlineFieldRow>
{query.queryType === 'search' && linkedDatasource && (
<>
<InlineLabel>
Tempo uses {((linkedDatasource as unknown) as DataSourceApi).name} to find traces.
</InlineLabel>
<LokiQueryField
datasource={linkedDatasource!}
onChange={this.onChangeLinkedQuery}
onRunQuery={this.onRunLinkedQuery}
query={this.props.query.linkedQuery ?? ({ refId: 'linked' } as any)}
history={[]}
/>
</>
)}
{query.queryType === 'search' && !linkedDatasource && (
<div className="text-warning">Please set up a Traces-to-logs datasource in the datasource settings.</div>
{query.queryType === 'search' && (
<SearchSection
linkedDatasourceUid={logsDatasourceUid}
query={query}
onRunQuery={this.onRunLinkedQuery}
onChange={this.onChangeLinkedQuery}
/>
)}
{query.queryType === 'upload' && (
<div className={css({ padding: this.props.theme.spacing(2) })}>
@@ -112,7 +127,7 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
/>
</div>
)}
{(!query.queryType || query.queryType === 'traceId') && (
{query.queryType === 'traceId' && (
<LegacyForms.FormField
label="Trace ID"
labelWidth={4}
@@ -136,9 +151,94 @@ class TempoQueryFieldComponent extends React.PureComponent<Props, State> {
}
/>
)}
{query.queryType === 'serviceMap' && <ServiceMapSection graphDatasourceUid={graphDatasourceUid} />}
</>
);
}
}
function ServiceMapSection({ graphDatasourceUid }: { graphDatasourceUid?: string }) {
const dsState = useAsync(() => getDS(graphDatasourceUid), [graphDatasourceUid]);
if (dsState.loading) {
return null;
}
const ds = dsState.value as LokiDatasource;
if (!graphDatasourceUid) {
return <div className="text-warning">Please set up a service graph datasource in the datasource settings.</div>;
}
if (graphDatasourceUid && !ds) {
return (
<div className="text-warning">
Service graph datasource is configured but the data source no longer exists. Please configure existing data
source to use the service graph functionality.
</div>
);
}
return null;
}
interface SearchSectionProps {
linkedDatasourceUid?: string;
onChange: (value: LokiQuery) => void;
onRunQuery: () => void;
query: TempoQuery;
}
function SearchSection({ linkedDatasourceUid, onChange, onRunQuery, query }: SearchSectionProps) {
const dsState = useAsync(() => getDS(linkedDatasourceUid), [linkedDatasourceUid]);
if (dsState.loading) {
return null;
}
const ds = dsState.value as LokiDatasource;
if (ds) {
return (
<>
<InlineLabel>Tempo uses {ds.name} to find traces.</InlineLabel>
<LokiQueryField
datasource={ds}
onChange={onChange}
onRunQuery={onRunQuery}
query={query.linkedQuery ?? ({ refId: 'linked' } as any)}
history={[]}
/>
</>
);
}
if (!linkedDatasourceUid) {
return <div className="text-warning">Please set up a Traces-to-logs datasource in the datasource settings.</div>;
}
if (linkedDatasourceUid && !ds) {
return (
<div className="text-warning">
Traces-to-logs datasource is configured but the data source no longer exists. Please configure existing data
source to use the search.
</div>
);
}
return null;
}
async function getDS(uid?: string): Promise<DataSourceApi | undefined> {
if (!uid) {
return undefined;
}
const dsSrv = getDataSourceSrv();
try {
return await dsSrv.get(uid);
} catch (error) {
console.error('Failed to load data source', error);
return undefined;
}
}
export const TempoQueryField = withTheme2(TempoQueryFieldComponent);
@@ -0,0 +1,64 @@
import { css } from '@emotion/css';
import { DataSourcePluginOptionsEditorProps, GrafanaTheme, updateDatasourcePluginJsonDataOption } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, InlineField, InlineFieldRow, useStyles } from '@grafana/ui';
import React from 'react';
import { TempoJsonData } from './datasource';
interface Props extends DataSourcePluginOptionsEditorProps<TempoJsonData> {}
export function ServiceMapSettings({ options, onOptionsChange }: Props) {
const styles = useStyles(getStyles);
return (
<div className={css({ width: '100%' })}>
<h3 className="page-heading">Service map</h3>
<div className={styles.infoText}>
To allow querying service map data you have to select a Prometheus instance where the data is stored.
</div>
<InlineFieldRow className={styles.row}>
<InlineField tooltip="The Prometheus data source with the service map data" label="Data source" labelWidth={26}>
<DataSourcePicker
pluginId="prometheus"
current={options.jsonData.serviceMap?.datasourceUid}
noDefault={true}
width={40}
onChange={(ds) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'serviceMap', {
datasourceUid: ds.uid,
})
}
/>
</InlineField>
<Button
type={'button'}
variant={'secondary'}
size={'sm'}
fill={'text'}
onClick={() => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'serviceMap', {
datasourceUid: undefined,
});
}}
>
Clear
</Button>
</InlineFieldRow>
</div>
);
}
const getStyles = (theme: GrafanaTheme) => ({
infoText: css`
label: infoText;
padding-bottom: ${theme.spacing.md};
color: ${theme.colors.textSemiWeak};
`,
row: css`
label: row;
align-items: baseline;
`,
});
@@ -3,13 +3,15 @@ import {
dataFrameToJSON,
DataSourceInstanceSettings,
FieldType,
getDefaultTimeRange,
LoadingState,
MutableDataFrame,
PluginType,
} from '@grafana/data';
import { BackendDataSourceResponse, FetchResponse, setBackendSrv } from '@grafana/runtime';
import { Observable, of } from 'rxjs';
import { createFetchResponse } from 'test/helpers/createFetchResponse';
import { TempoDatasource } from './datasource';
import { FetchResponse, setBackendSrv, BackendDataSourceResponse, setDataSourceSrv } from '@grafana/runtime';
import mockJson from './mockJsonResponse.json';
describe('Tempo data source', () => {
@@ -77,6 +79,30 @@ describe('Tempo data source', () => {
]);
});
it('runs service map queries', async () => {
const ds = new TempoDatasource({
...defaultSettings,
jsonData: {
serviceMap: {
datasourceUid: 'prom',
},
},
});
setDataSourceSrv(backendSrvWithPrometheus as any);
const response = await ds
.query({ targets: [{ queryType: 'serviceMap' }], range: getDefaultTimeRange() } as any)
.toPromise();
expect(response.data).toHaveLength(2);
expect(response.data[0].name).toBe('Nodes');
expect(response.data[0].fields[0].values.length).toBe(3);
expect(response.data[1].name).toBe('Edges');
expect(response.data[1].fields[0].values.length).toBe(2);
expect(response.state).toBe(LoadingState.Done);
});
it('should handle json file upload', async () => {
const ds = new TempoDatasource(defaultSettings);
ds.uploadedJson = JSON.stringify(mockJson);
@@ -93,6 +119,19 @@ describe('Tempo data source', () => {
});
});
const backendSrvWithPrometheus = {
async get(uid: string) {
if (uid === 'prom') {
return {
query() {
return of({ data: [totalsPromMetric] }, { data: [secondsPromMetric] });
},
};
}
throw new Error('unexpected uid');
},
};
function setupBackendSrv(frame: DataFrame) {
setBackendSrv({
fetch(): Observable<FetchResponse<BackendDataSourceResponse>> {
@@ -113,11 +152,11 @@ const defaultSettings: DataSourceInstanceSettings = {
id: 0,
uid: '0',
type: 'tracing',
name: 'jaeger',
name: 'tempo',
access: 'proxy',
meta: {
id: 'jaeger',
name: 'jaeger',
id: 'tempo',
name: 'tempo',
type: PluginType.datasource,
info: {} as any,
module: '',
@@ -125,3 +164,29 @@ const defaultSettings: DataSourceInstanceSettings = {
},
jsonData: {},
};
const totalsPromMetric = new MutableDataFrame({
refId: 'tempo_service_graph_request_total',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #tempo_service_graph_request_total', values: [10, 20] },
],
});
const secondsPromMetric = new MutableDataFrame({
refId: 'tempo_service_graph_request_server_seconds_sum',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #tempo_service_graph_request_server_seconds_sum', values: [10, 40] },
],
});
@@ -1,54 +1,66 @@
import { groupBy } from 'lodash';
import {
DataQuery,
DataQueryRequest,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
DataSourceJsonData,
LoadingState,
} from '@grafana/data';
import { DataSourceWithBackend } from '@grafana/runtime';
import { TraceToLogsData, TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings';
import { TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { from, merge, Observable, of, throwError } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators';
import { LokiOptions } from '../loki/types';
import { transformFromOTLP as transformFromOTEL, transformTrace, transformTraceList } from './resultTransformer';
import { map, mergeMap, toArray } from 'rxjs/operators';
import { LokiOptions, LokiQuery } from '../loki/types';
import { transformTrace, transformTraceList, transformFromOTLP as transformFromOTEL } from './resultTransformer';
import { PrometheusDatasource } from '../prometheus/datasource';
import { PromQuery } from '../prometheus/types';
import { mapPromMetricsToServiceMap, serviceMapMetrics } from './graphTransform';
export type TempoQueryType = 'search' | 'traceId' | 'upload';
export type TempoQueryType = 'search' | 'traceId' | 'serviceMap' | 'upload';
export interface TempoJsonData extends DataSourceJsonData {
tracesToLogs?: TraceToLogsOptions;
serviceMap?: {
datasourceUid?: string;
};
}
export type TempoQuery = {
query: string;
// Query to find list of traces, e.g., via Loki
linkedQuery?: DataQuery;
linkedQuery?: LokiQuery;
queryType: TempoQueryType;
} & DataQuery;
export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TraceToLogsData> {
export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJsonData> {
tracesToLogs?: TraceToLogsOptions;
serviceMap?: {
datasourceUid?: string;
};
uploadedJson?: string | ArrayBuffer | null = null;
constructor(instanceSettings: DataSourceInstanceSettings<TraceToLogsData>) {
constructor(instanceSettings: DataSourceInstanceSettings<TempoJsonData>) {
super(instanceSettings);
this.tracesToLogs = instanceSettings.jsonData.tracesToLogs;
this.serviceMap = instanceSettings.jsonData.serviceMap;
}
query(options: DataQueryRequest<TempoQuery>): Observable<DataQueryResponse> {
const subQueries: Array<Observable<DataQueryResponse>> = [];
const filteredTargets = options.targets.filter((target) => !target.hide);
const searchTargets = filteredTargets.filter((target) => target.queryType === 'search');
const uploadTargets = filteredTargets.filter((target) => target.queryType === 'upload');
const traceTargets = filteredTargets.filter(
(target) => target.queryType === 'traceId' || target.queryType === undefined
);
const targets: { [type: string]: TempoQuery[] } = groupBy(filteredTargets, (t) => t.queryType || 'traceId');
// Run search queries on linked datasource
if (this.tracesToLogs?.datasourceUid && searchTargets.length > 0) {
if (this.tracesToLogs?.datasourceUid && targets.search?.length > 0) {
const dsSrv = getDatasourceSrv();
subQueries.push(
from(dsSrv.get(this.tracesToLogs.datasourceUid)).pipe(
mergeMap((linkedDatasource: DataSourceApi) => {
// Wrap linked query into a data request based on original request
const linkedRequest: DataQueryRequest = { ...options, targets: searchTargets.map((t) => t.linkedQuery!) };
const linkedRequest: DataQueryRequest = { ...options, targets: targets.search.map((t) => t.linkedQuery!) };
// Find trace matchers in derived fields of the linked datasource that's identical to this datasource
const settings: DataSourceInstanceSettings<LokiOptions> = (linkedDatasource as any).instanceSettings;
const traceLinkMatcher: string[] =
@@ -71,7 +83,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TraceToLo
);
}
if (uploadTargets.length) {
if (targets.upload?.length) {
if (this.uploadedJson) {
const otelTraceData = JSON.parse(this.uploadedJson as string);
if (!otelTraceData.batches) {
@@ -84,8 +96,12 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TraceToLo
}
}
if (traceTargets.length > 0) {
const traceRequest: DataQueryRequest<TempoQuery> = { ...options, targets: traceTargets };
if (this.serviceMap?.datasourceUid && targets.serviceMap?.length > 0) {
subQueries.push(serviceMapQuery(options, this.serviceMap.datasourceUid));
}
if (targets.traceId?.length > 0) {
const traceRequest: DataQueryRequest<TempoQuery> = { ...options, targets: targets.traceId };
subQueries.push(
super.query(traceRequest).pipe(
map((response) => {
@@ -121,3 +137,37 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TraceToLo
return query.query;
}
}
function queryServiceMapPrometheus(request: DataQueryRequest<PromQuery>, datasourceUid: string) {
return from(getDatasourceSrv().get(datasourceUid)).pipe(
mergeMap((ds) => {
return (ds as PrometheusDatasource).query(request);
})
);
}
function serviceMapQuery(request: DataQueryRequest<TempoQuery>, datasourceUid: string) {
return queryServiceMapPrometheus(makePromServiceMapRequest(request), datasourceUid).pipe(
// Just collect all the responses first before processing into node graph data
toArray(),
map((responses: DataQueryResponse[]) => {
return {
data: mapPromMetricsToServiceMap(responses, request.range),
state: LoadingState.Done,
};
})
);
}
function makePromServiceMapRequest(options: DataQueryRequest<TempoQuery>): DataQueryRequest<PromQuery> {
return {
...options,
targets: serviceMapMetrics.map((metric) => {
return {
refId: metric,
expr: `delta(${metric}[$__range])`,
instant: true,
};
}),
};
}
@@ -1,6 +1,6 @@
import { createGraphFrames } from './graphTransform';
import { createGraphFrames, mapPromMetricsToServiceMap } from './graphTransform';
import { bigResponse } from './testResponse';
import { DataFrameView, MutableDataFrame } from '@grafana/data';
import { ArrayVector, DataFrameView, dateTime, MutableDataFrame } from '@grafana/data';
describe('createGraphFrames', () => {
it('transforms basic response into nodes and edges frame', async () => {
@@ -58,6 +58,33 @@ describe('createGraphFrames', () => {
});
});
describe('mapPromMetricsToServiceMap', () => {
it('transforms prom metrics to service map', async () => {
const range = {
from: dateTime('2000-01-01T00:00:00'),
to: dateTime('2000-01-01T00:01:00'),
};
const [nodes, edges] = mapPromMetricsToServiceMap([{ data: [totalsPromMetric] }, { data: [secondsPromMetric] }], {
...range,
raw: range,
});
expect(nodes.fields).toMatchObject([
{ name: 'id', values: new ArrayVector(['db', 'app', 'lb']) },
{ name: 'title', values: new ArrayVector(['db', 'app', 'lb']) },
{ name: 'mainStat', values: new ArrayVector([1000, 2000, NaN]) },
{ name: 'secondaryStat', values: new ArrayVector([10, 20, NaN]) },
]);
expect(edges.fields).toMatchObject([
{ name: 'id', values: new ArrayVector(['app_db', 'lb_app']) },
{ name: 'source', values: new ArrayVector(['app', 'lb']) },
{ name: 'target', values: new ArrayVector(['db', 'app']) },
{ name: 'mainStat', values: new ArrayVector([10, 20]) },
{ name: 'secondaryStat', values: new ArrayVector([1000, 2000]) },
]);
});
});
const singleSpanResponse = new MutableDataFrame({
fields: [
{ name: 'traceID', values: ['04450900759028499335'] },
@@ -81,3 +108,29 @@ const missingSpanResponse = new MutableDataFrame({
{ name: 'duration', values: [14.984, 4.984] },
],
});
const totalsPromMetric = new MutableDataFrame({
refId: 'tempo_service_graph_request_total',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #tempo_service_graph_request_total', values: [10, 20] },
],
});
const secondsPromMetric = new MutableDataFrame({
refId: 'tempo_service_graph_request_server_seconds_sum',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #tempo_service_graph_request_server_seconds_sum', values: [10, 40] },
],
});
@@ -1,4 +1,13 @@
import { DataFrame, DataFrameView, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data';
import { groupBy } from 'lodash';
import {
DataFrame,
DataFrameView,
DataQueryResponse,
FieldDTO,
MutableDataFrame,
NodeGraphDataFrameFieldNames as Fields,
TimeRange,
} from '@grafana/data';
import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing';
interface Row {
@@ -117,3 +126,151 @@ function findTraceDuration(view: DataFrameView<Row>): number {
return traceEndTime - traceStartTime;
}
const secondsMetric = 'tempo_service_graph_request_server_seconds_sum';
const totalsMetric = 'tempo_service_graph_request_total';
export const serviceMapMetrics = [
secondsMetric,
totalsMetric,
// We don't show histogram in node graph at the moment but we could later add that into a node context menu.
// 'tempo_service_graph_request_seconds_bucket',
// 'tempo_service_graph_request_seconds_count',
// These are used for debugging the tempo collection so probably not useful for service map right now.
// 'tempo_service_graph_unpaired_spans_total',
// 'tempo_service_graph_untagged_spans_total',
];
/**
* Map response from multiple prometheus metrics into a node graph data frames with nodes and edges.
* @param responses
* @param range
*/
export function mapPromMetricsToServiceMap(responses: DataQueryResponse[], range: TimeRange): [DataFrame, DataFrame] {
const [totalsDFView, secondsDFView] = getMetricFrames(responses);
// First just collect data from the metrics into a map with nodes and edges as keys
const nodesMap: Record<string, any> = {};
const edgesMap: Record<string, any> = {};
// At this moment we don't have any error/success or other counts so we just use these 2
collectMetricData(totalsDFView, 'total', totalsMetric, nodesMap, edgesMap);
collectMetricData(secondsDFView, 'seconds', secondsMetric, nodesMap, edgesMap);
return convertToDataFrames(nodesMap, edgesMap, range);
}
function createServiceMapDataFrames() {
function createDF(name: string, fields: FieldDTO[]) {
return new MutableDataFrame({ name, fields, meta: { preferredVisualisationType: 'nodeGraph' } });
}
const nodes = createDF('Nodes', [
{ name: Fields.id },
{ name: Fields.title },
{ name: Fields.mainStat, config: { unit: 'ms/t', displayName: 'Average response time' } },
{
name: Fields.secondaryStat,
config: { unit: 't/min', displayName: 'Transactions per minute' },
},
]);
const edges = createDF('Edges', [
{ name: Fields.id },
{ name: Fields.source },
{ name: Fields.target },
{ name: Fields.mainStat, config: { unit: 't', displayName: 'Transactions' } },
{ name: Fields.secondaryStat, config: { unit: 'ms/t', displayName: 'Average response time' } },
]);
return [nodes, edges];
}
function getMetricFrames(responses: DataQueryResponse[]) {
const responsesMap = groupBy(responses, (r) => r.data[0].refId);
const totalsDFView = new DataFrameView(responsesMap[totalsMetric][0].data[0]);
const secondsDFView = new DataFrameView(responsesMap[secondsMetric][0].data[0]);
return [totalsDFView, secondsDFView];
}
/**
* Collect data from a metric into a map of nodes and edges. The metric data is modeled as counts of metric per edge
* which is a pair of client-server nodes. This means we convert each row of the metric 1-1 to edges and than we assign
* the metric also to server. We count the stats for server only as we show requests/transactions that particular node
* processed not those which it generated and other stats like average transaction time then stem from that.
* @param frame
* @param stat
* @param metric
* @param nodesMap
* @param edgesMap
*/
function collectMetricData(
frame: DataFrameView,
stat: 'total' | 'seconds',
metric: string,
nodesMap: Record<string, any>,
edgesMap: Record<string, any>
) {
// The name of the value column is in this format
// TODO figure out if it can be changed
const valueName = `Value #${metric}`;
for (let i = 0; i < frame.length; i++) {
const row = frame.get(i);
const edgeId = `${row.client}_${row.server}`;
if (!edgesMap[edgeId]) {
edgesMap[edgeId] = {
target: row.server,
source: row.client,
[stat]: row[valueName],
};
} else {
edgesMap[edgeId][stat] = (edgesMap[edgeId][stat] || 0) + row[valueName];
}
if (!nodesMap[row.server]) {
nodesMap[row.server] = {
[stat]: row[valueName],
};
} else {
nodesMap[row.server][stat] = (nodesMap[row.server][stat] || 0) + row[valueName];
}
if (!nodesMap[row.client]) {
nodesMap[row.client] = {
[stat]: 0,
};
}
}
}
function convertToDataFrames(
nodesMap: Record<string, any>,
edgesMap: Record<string, any>,
range: TimeRange
): [DataFrame, DataFrame] {
const rangeMs = range.to.valueOf() - range.from.valueOf();
const [nodes, edges] = createServiceMapDataFrames();
for (const nodeId of Object.keys(nodesMap)) {
const node = nodesMap[nodeId];
nodes.add({
id: nodeId,
title: nodeId,
// NaN will not be shown in the node graph. This happens for a root client node which did not process
// any requests itself.
mainStat: node.total ? (node.seconds / node.total) * 1000 : Number.NaN,
secondaryStat: node.total ? node.total / (rangeMs / (1000 * 60)) : Number.NaN,
});
}
for (const edgeId of Object.keys(edgesMap)) {
const edge = edgesMap[edgeId];
edges.add({
id: edgeId,
source: edge.source,
target: edge.target,
mainStat: edge.total,
secondaryStat: edge.total ? (edge.seconds / edge.total) * 1000 : Number.NaN,
});
}
return [nodes, edges];
}
@@ -0,0 +1,47 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaschema
import (
ui "github.com/grafana/grafana/cue/ui:grafanaschema"
)
Family: {
lineages: [
[
{
PanelOptions: {
ui.OptionsWithLegend
ui.OptionsWithTooltip
ui.OptionsWithTextFormatting
orientation: ui.VizOrientation
// TODO this default is a guess based on common devenv values
stacking: ui.StackingMode | *"none"
showValue: ui.BarValueVisibility
barWidth: number
groupWidth: number
}
PanelFieldConfig: {
ui.AxisConfig
ui.HideableFieldConfig
lineWidth?: number
fillOpacity?: number
gradientMode?: ui.GraphGradientMode
}
}
]
]
migrations: []
}
@@ -0,0 +1,32 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaschema
import ui "github.com/grafana/grafana/cue/ui:grafanaschema"
Family: {
lineages: [
[
{
PanelOptions: {
ui.SingleStatBaseOptions
displayMode: ui.BarGaugeDisplayMode
showUnfilled: bool
}
}
]
]
migrations: []
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaschema
import ui "github.com/grafana/grafana/cue/ui:grafanaschema"
Family: {
lineages: [
[
{
PanelOptions: {
ui.SingleStatBaseOptions
showThresholdLabels: bool
showThresholdMarkers: bool
}
}
]
]
migrations: []
}
+21 -1
View File
@@ -1,16 +1,36 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaschema
import ui "github.com/grafana/grafana/cue/ui:grafanaschema"
Family: {
lineages: [
[
{
PanelOptions: {
ui.OptionsWithLegend
ui.OptionsWithTooltip
bucketSize?: int
bucketOffset: int | *0
combine?: bool
}
// TODO: FieldConfig
PanelFieldConfig: {
ui.GraphFieldConfig
}
}
]
]
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaschema
import ui "github.com/grafana/grafana/cue/ui:grafanaschema"
Family: {
lineages: [
[
{
PanelOptions: {
ui.SingleStatBaseOptions
graphMode: ui.BigValueGraphMode
colorMode: ui.BigValueColorMode
justifyMode: ui.BigValueJustifyMode
textMode: ui.BigValueTextMode
}
}
]
]
migrations: []
}
@@ -0,0 +1,47 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaschema
import (
ui "github.com/grafana/grafana/cue/ui:grafanaschema"
)
Family: {
lineages: [
[
{
#TimelineMode: "changes" | "samples" @cuetsy(targetType="enum")
#TimelineValueAlignment: "center" | "left" | "right" @cuetsy(targetType="type")
PanelOptions: {
// FIXME ts comments indicate this shouldn't be in the saved model, but currently is emitted
mode?: #TimelineMode
ui.OptionsWithLegend
ui.OptionsWithTooltip
showValue: ui.BarValueVisibility | *"auto"
rowHeight: number | *0.9
colWidth?: number
mergeValues?: bool | *true
alignValue?: #TimelineValueAlignment | *"left"
}
PanelFieldConfig: {
ui.HideableFieldConfig
lineWidth?: number | *0
fillOpacity?: number | *70
}
}
]
]
migrations: []
}
@@ -0,0 +1,42 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaschema
import (
ui "github.com/grafana/grafana/cue/ui:grafanaschema"
)
Family: {
lineages: [
[
{
PanelOptions: {
ui.OptionsWithLegend
ui.OptionsWithTooltip
showValue: ui.BarValueVisibility
rowHeight: number
colWidth?: number
alignValue: "center" | *"left" | "right"
}
PanelFieldConfig: {
ui.HideableFieldConfig
lineWidth?: number | *1
fillOpacity?: number | *70
}
}
]
]
migrations: []
}

Some files were not shown because too many files have changed in this diff Show More