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:
+2
-2
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{},
|
||||
}},
|
||||
}}))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+1
-1
@@ -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={
|
||||
|
||||
+35
-5
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-4
@@ -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]);
|
||||
|
||||
|
||||
+2
-2
@@ -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 = {
|
||||
|
||||
-34
@@ -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;
|
||||
+36
@@ -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;
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+40
@@ -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}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
|
||||
}
|
||||
}
|
||||
queryType="range"
|
||||
resolution={1}
|
||||
/>
|
||||
}
|
||||
data={
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+3
-1
@@ -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: []
|
||||
}
|
||||
@@ -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: []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user