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

This commit is contained in:
Piotr Jamróz
2021-08-16 10:23:54 +02:00
141 changed files with 3672 additions and 1576 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ shellcheck: $(SH_FILES) ## Run checks for shell scripts.
build-docker-dev: ## Build Docker image for development (fast). build-docker-dev: ## Build Docker image for development (fast).
@echo "build development container" @echo "build development container"
@echo "\033[92mInfo:\033[0m the frontend code is expected to be built already." @echo "\033[92mInfo:\033[0m the frontend code is expected to be built already."
$(GO) run build.go -goos linux -pkg-arch amd64 ${OPT} build pkg-archive latest $(GO) run build.go -goos linux -pkg-arch amd64 ${OPT} build latest
cp dist/grafana-latest.linux-x64.tar.gz packaging/docker cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
cd packaging/docker && docker build --tag grafana/grafana:dev . cd packaging/docker && docker build --tag grafana/grafana:dev .
@@ -141,7 +141,7 @@ clean: ## Clean up intermediate build artifacts.
rm -rf public/build rm -rf public/build
# This repository's configuration is protected (https://readme.drone.io/signature/). # This repository's configuration is protected (https://readme.drone.io/signature/).
# Use this make target to regenerate the configuration YAML files when # Use this make target to regenerate the configuration YAML files when
# you modify starlark files. # you modify starlark files.
drone: drone:
drone starlark drone starlark
+5
View File
@@ -715,6 +715,11 @@ global_session = -1
# global limit of alerts # global limit of alerts
global_alert_rule = -1 global_alert_rule = -1
#################################### Unified Alerting ####################
[unified_alerting]
# Specify the frequency of polling for admin config changes.
admin_config_poll_interval_seconds = 60
#################################### Alerting ############################ #################################### Alerting ############################
[alerting] [alerting]
# Disable alerting engine & UI features # Disable alerting engine & UI features
+5
View File
@@ -698,6 +698,11 @@
# global limit of alerts # global limit of alerts
;global_alert_rule = -1 ;global_alert_rule = -1
#################################### Unified Alerting ####################
[unified_alerting]
# Specify the frequency of polling for admin config changes.
;admin_config_poll_interval_seconds = 60
#################################### Alerting ############################ #################################### Alerting ############################
[alerting] [alerting]
# Disable alerting engine & UI features # Disable alerting engine & UI features
@@ -50,6 +50,10 @@ The following guidelines are specific to Grafana documentation. For the most par
### General ### General
Per the [Voice and tone](https://developers.google.com/style/tone) section of the Google developer documentation style guide:
> In your documents, aim for a voice and tone that's conversational, friendly, and respectful without being overly colloquial or frivolous; a voice that's casual and natural and approachable, not pedantic or pushy. Try to sound like a knowledgeable friend who understands what the developer wants to do.
- Use active voice: - Use active voice:
- Active: Grafana displays the heatmap visualization. - Active: Grafana displays the heatmap visualization.
- Passive: The heatmap visualization is displayed. - Passive: The heatmap visualization is displayed.
+104 -1
View File
@@ -73,7 +73,7 @@ Family: scuemata.#Family & {
schemaVersion: number | *30 schemaVersion: number | *30
// Version of the dashboard, incremented each time the dashboard is updated. // Version of the dashboard, incremented each time the dashboard is updated.
version?: number version?: number
panels?: [...#Panel] panels?: [...(#Panel | #GraphPanel | #RowPanel)]
// TODO docs // TODO docs
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(targetType="enum") #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(targetType="enum")
@@ -191,6 +191,12 @@ Family: scuemata.#Family & {
// TODO docs // TODO docs
maxDataPoints?: number maxDataPoints?: number
// TODO docs
thresholds?: [...]
// TODO docs
timeRegions?: [...]
// TODO docs // TODO docs
// TODO tighter constraint // TODO tighter constraint
interval?: string interval?: string
@@ -205,6 +211,7 @@ Family: scuemata.#Family & {
// The values depend on panel type // The values depend on panel type
options: {...} options: {...}
fieldConfig: { fieldConfig: {
defaults: { defaults: {
// The display value for this field. This supports template variables blank is auto // The display value for this field. This supports template variables blank is auto
@@ -289,6 +296,102 @@ Family: scuemata.#Family & {
}] }]
} }
} }
// Row panel
#RowPanel: {
type: "row"
collapsed: bool | *false
title?: string
// Name of default datasource.
datasource?: string
gridPos?: {
// Panel
h: number & >0 | *9
// Panel
w: number & >0 & <=24 | *12
// Panel x
x: number & >=0 & <24 | *0
// Panel y
y: number & >=0 | *0
// true if fixed
static?: bool
}
id: number
panels: [...#Panel | #GraphPanel]
}
// Support for legacy graph panels.
#GraphPanel: {
...
type: "graph"
thresholds: [...{...}]
timeRegions: [...{...}]
// FIXME this one is quite complicated, as it duplicates the #Panel object's own structure (...?)
seriesOverrides: [...{...}]
// TODO docs
// TODO tighter constraint
aliasColors?: [string]: string
// TODO docs
bars: bool | *false
// TODO docs
dashes: bool | *false
// TODO docs
dashLength: number | *10
// TODO docs
// TODO tighter constraint
fill?: number
// TODO docs
// TODO tighter constraint
fillGradient?: number
// TODO docs
hiddenSeries: bool | *false
// FIXME idk where this comes from, leaving it very open and very wrong for now
legend: {...}
// TODO docs
// TODO tighter constraint
lines: bool | *false
// TODO docs
linewidth?: number
// TODO docs
nullPointMode: *"null" | "connected" | "null as zero"
// TODO docs
percentage: bool | *false
// TODO docs
points: bool | *false
// TODO docs
// FIXME this is the kind of case that makes
// optional/non-default tricky: it's optional because it
// only makes sense when points is true (right?), but if it
// is, then there actually is a default value. Easier way to
// represent this would be to wrap up this handling into a
// struct
pointradius?: number
// TODO docs
// TODO tighter constraint
renderer: string
// TODO docs
spaceLength: number | *10
// TODO docs
stack: bool | *false
// TODO docs
steppedLine: bool | *false
// TODO docs
tooltip?: {
// TODO docs
shared?: bool
// TODO docs
sort: number | *0
// TODO docs
// FIXME literally no idea if these values are sane
value_type: *"individual" | "cumulative"
}
}
} }
] ]
] ]
+3 -1
View File
@@ -40,7 +40,7 @@ LineConfig: {
lineWidth?: number lineWidth?: number
lineInterpolation?: LineInterpolation lineInterpolation?: LineInterpolation
lineStyle?: LineStyle lineStyle?: LineStyle
spanNulls?: bool spanNulls?: bool | number
} @cuetsy(targetType="interface") } @cuetsy(targetType="interface")
FillConfig: { FillConfig: {
fillColor?: string fillColor?: string
@@ -86,6 +86,8 @@ GraphFieldConfig: LineConfig & FillConfig & PointsConfig & AxisConfig & {
VizLegendOptions: { VizLegendOptions: {
displayMode: LegendDisplayMode displayMode: LegendDisplayMode
placement: LegendPlacement placement: LegendPlacement
asTable: bool | *false
isVisible: bool | *false
calcs: [...string] calcs: [...string]
} @cuetsy(targetType="interface") } @cuetsy(targetType="interface")
VizTooltipOptions: { VizTooltipOptions: {
@@ -10485,7 +10485,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -10613,7 +10613,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -259,10 +259,9 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false
"viz": false
}, },
"lineInterpolation": "linear", "lineInterpolation": "linear",
"lineWidth": 1, "lineWidth": 1,
@@ -549,7 +548,7 @@
"fillOpacity": 74, "fillOpacity": 74,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -665,7 +664,7 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -806,7 +805,7 @@
"fillOpacity": 50, "fillOpacity": 50,
"gradientMode": "hue", "gradientMode": "hue",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -924,7 +923,7 @@
"fillOpacity": 40, "fillOpacity": 40,
"gradientMode": "hue", "gradientMode": "hue",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -1042,7 +1041,7 @@
"fillOpacity": 40, "fillOpacity": 40,
"gradientMode": "hue", "gradientMode": "hue",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -1183,12 +1182,9 @@
"barAlignment": 0, "barAlignment": 0,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 10, "fillOpacity": 10,
"gradientMode": { "gradientMode": "none",
"label": "None",
"value": "none"
},
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -1289,12 +1285,9 @@
"barAlignment": 0, "barAlignment": 0,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 10, "fillOpacity": 10,
"gradientMode": { "gradientMode": "none",
"label": "None",
"value": "none"
},
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -1399,12 +1392,9 @@
"barAlignment": 0, "barAlignment": 0,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 10, "fillOpacity": 10,
"gradientMode": { "gradientMode": "none",
"label": "None",
"value": "none"
},
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -1533,7 +1523,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "opacity", "gradientMode": "opacity",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -1768,12 +1758,9 @@
"barAlignment": 0, "barAlignment": 0,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": { "gradientMode": "none",
"label": "None",
"value": "none"
},
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -2093,12 +2080,9 @@
"barAlignment": 0, "barAlignment": 0,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": { "gradientMode": "none",
"label": "None",
"value": "none"
},
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -2420,7 +2404,7 @@
"fillOpacity": 82, "fillOpacity": 82,
"gradientMode": "hue", "gradientMode": "hue",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -2566,7 +2550,7 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "hue", "gradientMode": "hue",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false "tooltip": false
}, },
@@ -35,7 +35,6 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -106,7 +105,6 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -177,7 +175,6 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -247,7 +244,6 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -318,7 +314,6 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -388,7 +383,6 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -460,7 +454,6 @@
"fillOpacity": 80, "fillOpacity": 80,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -149,7 +149,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -265,7 +265,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -400,7 +400,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -507,7 +507,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -623,7 +623,7 @@
"fillOpacity": 0, "fillOpacity": 0,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -758,7 +758,7 @@
"fillOpacity": 10, "fillOpacity": 10,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -902,7 +902,7 @@
"fillOpacity": 10, "fillOpacity": 10,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -1045,7 +1045,7 @@
"fillOpacity": 10, "fillOpacity": 10,
"gradientMode": "none", "gradientMode": "none",
"hideFrom": { "hideFrom": {
"graph": false, "viz": false,
"legend": false, "legend": false,
"tooltip": false, "tooltip": false,
"viz": false "viz": false
@@ -49,10 +49,6 @@
}, },
"showPoints": "auto", "showPoints": "auto",
"spanNulls": false, "spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": { "thresholdsStyle": {
"mode": "line" "mode": "line"
} }
@@ -1,5 +1,5 @@
influxdb1: influxdb1:
image: influxdb:1.8.6 image: influxdb:1.8
container_name: influxdb1 container_name: influxdb1
ports: ports:
- '2004:2004' - '2004:2004'
@@ -1099,6 +1099,16 @@ Sets a global limit on number of alert rules that can be created. Default is -1
<hr> <hr>
## [unified_alerting]
For more information about the Unified Alerting feature in Grafana, refer to [Unified Alerting]({{< relref "../unified-alerting/_index.md" >}}}).
### admin_config_poll_interval_seconds
Specify the frequency of polling for admin config changes. The default value is `60`.
<hr>
## [alerting] ## [alerting]
For more information about the Alerting feature in Grafana, refer to [Alerts overview]({{< relref "../alerting/_index.md" >}}). For more information about the Alerting feature in Grafana, refer to [Alerts overview]({{< relref "../alerting/_index.md" >}}).
@@ -147,6 +147,8 @@ To set up PagerDuty, all you have to do is to provide an integration key.
> **Note:** The `state` tag overrides the current alert state inside the `custom_details` payload. > **Note:** The `state` tag overrides the current alert state inside the `custom_details` payload.
> **Note:** Grafana uses the `Events API V2` integration. This can be configured for each service.
### VictorOps ### VictorOps
To configure VictorOps, provide the URL from the Grafana Integration and substitute `$routing_key` with a valid key. To configure VictorOps, provide the URL from the Grafana Integration and substitute `$routing_key` with a valid key.
@@ -120,6 +120,7 @@ query(options: DataQueryRequest<MyQuery>): Observable<DataQueryResponse> {
subscriber.next({ subscriber.next({
data: [frame], data: [frame],
key: query.refId, key: query.refId,
state: LoadingState.Streaming,
}); });
}, 100); }, 100);
@@ -26,7 +26,7 @@ To verify ownership of your plugin, you need to generate an API key that you'll
You can find the plugin ID in the `plugin.json` file inside your plugin directory. For example, if your account slug is `acmecorp`, you need to prefix the plugin ID with `acmecorp-`. You can find the plugin ID in the `plugin.json` file inside your plugin directory. For example, if your account slug is `acmecorp`, you need to prefix the plugin ID with `acmecorp-`.
1. [Create a Grafana Cloud API key](https://grafana.com/docs/grafana-cloud/cloud-portal/create-api-key/) with the **PluginPublisher** role. 1. [Create a Grafana Cloud API key](https://grafana.com/docs/grafana-cloud/reference/create-api-key/) with the **PluginPublisher** role.
## Sign a public plugin ## Sign a public plugin
@@ -6,7 +6,7 @@ weight = 200
# Add a custom variable # Add a custom variable
Use a _custom_ variable for values that do not change. This might be numbers, strings, or even other variables. Use a _custom_ variable for a value that does not change, such as a number or a string.
For example, if you have server names or region names that never change, then you might want to create them as custom variables rather than query variables. Because they do not change, you might use them in [chained variables]({{< relref "chained-variables.md" >}}) rather than other query variables. That would reduce the number of queries Grafana must send when chained variables are updated. For example, if you have server names or region names that never change, then you might want to create them as custom variables rather than query variables. Because they do not change, you might use them in [chained variables]({{< relref "chained-variables.md" >}}) rather than other query variables. That would reduce the number of queries Grafana must send when chained variables are updated.
@@ -24,7 +24,7 @@ For example, if you have server names or region names that never change, then yo
## Enter Custom Options ## Enter Custom Options
1. In the **Values separated by comma** list, enter the values for this variable in a comma-separated list. You can include numbers, strings, other variables or key/value pairs separated by a space and a colon, i.e. `key1 : value1,key2 : value2`. 1. In the **Values separated by comma** list, enter the values for this variable in a comma-separated list. You can include numbers, strings, or key/value pairs separated by a space and a colon. For example, `key1 : value1,key2 : value2`.
1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). 1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}).
1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. 1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect.
1. Click **Add** to add the variable to the dashboard. 1. Click **Add** to add the variable to the dashboard.
+2 -1
View File
@@ -52,7 +52,7 @@ require (
github.com/gorilla/websocket v1.4.2 github.com/gorilla/websocket v1.4.2
github.com/gosimple/slug v1.9.0 github.com/gosimple/slug v1.9.0
github.com/grafana/grafana-aws-sdk v0.7.0 github.com/grafana/grafana-aws-sdk v0.7.0
github.com/grafana/grafana-plugin-sdk-go v0.111.0 github.com/grafana/grafana-plugin-sdk-go v0.112.0
github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103 github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
github.com/hashicorp/go-hclog v0.16.1 github.com/hashicorp/go-hclog v0.16.1
@@ -67,6 +67,7 @@ require (
github.com/laher/mergefs v0.1.1 github.com/laher/mergefs v0.1.1
github.com/lib/pq v1.10.0 github.com/lib/pq v1.10.0
github.com/linkedin/goavro/v2 v2.10.0 github.com/linkedin/goavro/v2 v2.10.0
github.com/m3db/prometheus_remote_client_golang v0.4.4 // indirect
github.com/magefile/mage v1.11.0 github.com/magefile/mage v1.11.0
github.com/mattn/go-isatty v0.0.12 github.com/mattn/go-isatty v0.0.12
github.com/mattn/go-sqlite3 v1.14.7 github.com/mattn/go-sqlite3 v1.14.7
+4
View File
@@ -995,6 +995,8 @@ github.com/grafana/grafana-aws-sdk v0.7.0/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbR
github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60= github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60=
github.com/grafana/grafana-plugin-sdk-go v0.111.0 h1:7eqLgAdDYeELVokRCeDEobYQJy5/E5C43tZkb/xH66Q= github.com/grafana/grafana-plugin-sdk-go v0.111.0 h1:7eqLgAdDYeELVokRCeDEobYQJy5/E5C43tZkb/xH66Q=
github.com/grafana/grafana-plugin-sdk-go v0.111.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= github.com/grafana/grafana-plugin-sdk-go v0.111.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/grafana-plugin-sdk-go v0.112.0 h1:c+3kKJQ1wVZ5pMSF40eUizB5YzGPAbO+2QG0CJ3d4bI=
github.com/grafana/grafana-plugin-sdk-go v0.112.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103 h1:qCmofFVwQR9QnsinstVqI1NPLMVl33jNCnOCXEAVn6E= github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103 h1:qCmofFVwQR9QnsinstVqI1NPLMVl33jNCnOCXEAVn6E=
github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103/go.mod h1:GHIsn+EohCChsdu5YouNZewqLeV9L2FNw4DEJU3P9qE= github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103/go.mod h1:GHIsn+EohCChsdu5YouNZewqLeV9L2FNw4DEJU3P9qE=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
@@ -1327,6 +1329,8 @@ github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/iostat v1.1.0/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= github.com/lufia/iostat v1.1.0/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/m3db/prometheus_remote_client_golang v0.4.4 h1:DsAIjVKoCp7Ym35tAOFL1OuMLIdIikAEHeNPHY+yyM8=
github.com/m3db/prometheus_remote_client_golang v0.4.4/go.mod h1:wHfVbA3eAK6dQvKjCkHhusWYegCk3bDGkA15zymSHdc=
github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls=
+5 -5
View File
@@ -183,7 +183,7 @@
"mutationobserver-shim": "0.3.3", "mutationobserver-shim": "0.3.3",
"ngtemplate-loader": "2.0.1", "ngtemplate-loader": "2.0.1",
"nodemon": "2.0.2", "nodemon": "2.0.2",
"optimize-css-assets-webpack-plugin": "5.0.4", "optimize-css-assets-webpack-plugin": "5.0.5",
"postcss-browser-reporter": "0.6.0", "postcss-browser-reporter": "0.6.0",
"postcss-loader": "3.0.0", "postcss-loader": "3.0.0",
"postcss-reporter": "6.0.1", "postcss-reporter": "6.0.1",
@@ -221,6 +221,9 @@
"@emotion/react": "11.1.5", "@emotion/react": "11.1.5",
"@grafana/aws-sdk": "0.0.3", "@grafana/aws-sdk": "0.0.3",
"@grafana/slate-react": "0.22.10-grafana", "@grafana/slate-react": "0.22.10-grafana",
"@opentelemetry/api": "1.0.2",
"@opentelemetry/exporter-collector": "0.23.0",
"@opentelemetry/semantic-conventions": "0.23.0",
"@popperjs/core": "2.5.4", "@popperjs/core": "2.5.4",
"@reduxjs/toolkit": "1.5.0", "@reduxjs/toolkit": "1.5.0",
"@sentry/browser": "5.25.0", "@sentry/browser": "5.25.0",
@@ -265,12 +268,9 @@
"mousetrap": "1.6.5", "mousetrap": "1.6.5",
"mousetrap-global-bind": "1.1.0", "mousetrap-global-bind": "1.1.0",
"ol": "^6.5.0", "ol": "^6.5.0",
"@opentelemetry/api": "1.0.2",
"@opentelemetry/exporter-collector": "0.23.0",
"@opentelemetry/semantic-conventions": "0.23.0",
"papaparse": "5.3.0", "papaparse": "5.3.0",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"prismjs": "1.23.0", "prismjs": "1.24.0",
"prop-types": "15.7.2", "prop-types": "15.7.2",
"rc-cascader": "1.0.1", "rc-cascader": "1.0.1",
"re-resizable": "^6.2.0", "re-resizable": "^6.2.0",
@@ -255,6 +255,10 @@ export function emphasize(color: string, coefficient = 0.15) {
* @beta * @beta
*/ */
export function alpha(color: string, value: number) { export function alpha(color: string, value: number) {
if (color === '') {
return '#000000';
}
value = clamp(value); value = clamp(value);
// hex 6, hex 8 (w/alpha) // hex 6, hex 8 (w/alpha)
@@ -1,33 +1,28 @@
import { of } from 'rxjs';
import { seriesToColumnsTransformer } from './seriesToColumns'; import { seriesToColumnsTransformer } from './seriesToColumns';
import { DataFrame } from '../../types/dataFrame'; import { DataFrame } from '../../types/dataFrame';
import { getTimeField } from '../../dataframe/processDataFrame'; import { getTimeField } from '../../dataframe/processDataFrame';
import { DataTransformerInfo } from '../../types/transformations'; import { SynchronousDataTransformerInfo } from '../../types/transformations';
import { DataTransformerID } from './ids'; import { DataTransformerID } from './ids';
import { mergeMap } from 'rxjs/operators'; import { map } from 'rxjs/operators';
export const ensureColumnsTransformer: DataTransformerInfo = { export const ensureColumnsTransformer: SynchronousDataTransformerInfo = {
id: DataTransformerID.ensureColumns, id: DataTransformerID.ensureColumns,
name: 'Ensure Columns Transformer', name: 'Ensure Columns Transformer',
description: 'Will check if current data frames is series or columns. If in series it will convert to columns.', description: 'Will check if current data frames is series or columns. If in series it will convert to columns.',
operator: (options = {}) => (source) =>
source.pipe(
mergeMap((data) => {
// Assume timeseries should first be joined by time
const timeFieldName = findConsistentTimeFieldName(data);
if (data.length > 1 && timeFieldName) { operator: (options) => (source) => source.pipe(map((data) => ensureColumnsTransformer.transformer(options)(data))),
return of(data).pipe(
seriesToColumnsTransformer.operator({
byField: timeFieldName,
})
);
}
return of(data); transformer: (options: any) => (frames: DataFrame[]) => {
}) // Assume timeseries should first be joined by time
), const timeFieldName = findConsistentTimeFieldName(frames);
if (frames.length > 1 && timeFieldName) {
return seriesToColumnsTransformer.transformer({
byField: timeFieldName,
})(frames);
}
return frames;
},
}; };
/** /**
@@ -1,4 +1,4 @@
import { DataTransformerInfo } from '../../types'; import { SynchronousDataTransformerInfo } from '../../types';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { DataTransformerID } from './ids'; import { DataTransformerID } from './ids';
@@ -73,7 +73,7 @@ export const histogramFieldInfo = {
/** /**
* @alpha * @alpha
*/ */
export const histogramTransformer: DataTransformerInfo<HistogramTransformerOptions> = { export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTransformerOptions> = {
id: DataTransformerID.histogram, id: DataTransformerID.histogram,
name: 'Histogram', name: 'Histogram',
description: 'Calculate a histogram from input data', description: 'Calculate a histogram from input data',
@@ -81,23 +81,18 @@ export const histogramTransformer: DataTransformerInfo<HistogramTransformerOptio
fields: {}, fields: {},
}, },
/** operator: (options) => (source) => source.pipe(map((data) => histogramTransformer.transformer(options)(data))),
* Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series transformer: (options: HistogramTransformerOptions) => (data: DataFrame[]) => {
*/ if (!Array.isArray(data) || data.length === 0) {
operator: (options) => (source) => return data;
source.pipe( }
map((data) => { const hist = buildHistogram(data, options);
if (!Array.isArray(data) || data.length === 0) { if (hist == null) {
return data; return [];
} }
const hist = buildHistogram(data, options); return [histogramFieldsToFrame(hist)];
if (hist == null) { },
return [];
}
return [histogramFieldsToFrame(hist)];
})
),
}; };
/** /**
@@ -1,6 +1,6 @@
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { DataTransformerInfo, FieldMatcher } from '../../types'; import { DataFrame, SynchronousDataTransformerInfo, FieldMatcher } from '../../types';
import { DataTransformerID } from './ids'; import { DataTransformerID } from './ids';
import { outerJoinDataFrames } from './joinDataFrames'; import { outerJoinDataFrames } from './joinDataFrames';
import { fieldMatchers } from '../matchers'; import { fieldMatchers } from '../matchers';
@@ -10,27 +10,29 @@ export interface SeriesToColumnsOptions {
byField?: string; // empty will pick the field automatically byField?: string; // empty will pick the field automatically
} }
export const seriesToColumnsTransformer: DataTransformerInfo<SeriesToColumnsOptions> = { export const seriesToColumnsTransformer: SynchronousDataTransformerInfo<SeriesToColumnsOptions> = {
id: DataTransformerID.seriesToColumns, id: DataTransformerID.seriesToColumns,
name: 'Series as columns', // Called 'Outer join' in the UI! name: 'Series as columns', // Called 'Outer join' in the UI!
description: 'Groups series by field and returns values as columns', description: 'Groups series by field and returns values as columns',
defaultOptions: { defaultOptions: {
byField: undefined, // DEFAULT_KEY_FIELD, byField: undefined, // DEFAULT_KEY_FIELD,
}, },
operator: (options) => (source) =>
source.pipe( operator: (options) => (source) => source.pipe(map((data) => seriesToColumnsTransformer.transformer(options)(data))),
map((data) => {
if (data.length > 1) { transformer: (options: SeriesToColumnsOptions) => {
let joinBy: FieldMatcher | undefined = undefined; let joinBy: FieldMatcher | undefined = undefined;
if (options.byField) { return (data: DataFrame[]) => {
joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField); if (data.length > 1) {
} if (options.byField && !joinBy) {
const joined = outerJoinDataFrames({ frames: data, joinBy }); joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField);
if (joined) {
return [joined];
}
} }
return data; const joined = outerJoinDataFrames({ frames: data, joinBy });
}) if (joined) {
), return [joined];
}
}
return data;
};
},
}; };
@@ -5,6 +5,8 @@ import { RegistryItemWithOptions } from '../utils/Registry';
/** /**
* Function that transform data frames (AKA transformer) * Function that transform data frames (AKA transformer)
*
* @public
*/ */
export interface DataTransformerInfo<TOptions = any> extends RegistryItemWithOptions { export interface DataTransformerInfo<TOptions = any> extends RegistryItemWithOptions {
/** /**
@@ -14,6 +16,19 @@ export interface DataTransformerInfo<TOptions = any> extends RegistryItemWithOpt
operator: (options: TOptions) => MonoTypeOperatorFunction<DataFrame[]>; operator: (options: TOptions) => MonoTypeOperatorFunction<DataFrame[]>;
} }
/**
* Many transformations can be called with a simple synchronous function.
* When a transformer is defined, it should have identical behavior to using the operator
*
* @public
*/
export interface SynchronousDataTransformerInfo<TOptions = any> extends DataTransformerInfo<TOptions> {
transformer: (options: TOptions) => (frames: DataFrame[]) => DataFrame[];
}
/**
* @public
*/
export interface DataTransformerConfig<TOptions = any> { export interface DataTransformerConfig<TOptions = any> {
/** /**
* Unique identifier of transformer * Unique identifier of transformer
@@ -44,7 +44,7 @@ export const Components = {
}, },
Panels: { Panels: {
Panel: { Panel: {
title: (title: string) => `Panel header ${title}`, title: (title: string) => `data-testid Panel header ${title}`,
headerItems: (item: string) => `Panel header item ${item}`, headerItems: (item: string) => `Panel header item ${item}`,
containerByTitle: (title: string) => `${title} panel`, containerByTitle: (title: string) => `${title} panel`,
headerCornerInfo: (mode: string) => `Panel header ${mode}`, headerCornerInfo: (mode: string) => `Panel header ${mode}`,
@@ -57,17 +57,19 @@ export const bundlePlugin = async ({ watch, production, preserveConsole }: Plugi
} else { } else {
compiler.run((err: Error, stats: webpack.Stats) => { compiler.run((err: Error, stats: webpack.Stats) => {
if (err) { if (err) {
reject(err.message); reject(err);
return; return;
} }
if (stats.hasErrors()) { if (stats.hasErrors()) {
stats.compilation.errors.forEach((e) => { stats.compilation.errors.forEach((e) => {
console.log(e.message); console.log(e.message);
}); });
reject('Build failed'); reject('Build failed');
return;
} }
console.log('\n', stats.toString({ colors: true }), '\n'); console.log('\n', stats.toString({ colors: true }), '\n');
resolve(); resolve();
}); });
@@ -11,7 +11,8 @@ export const useSpinner = async (label: string, fn: () => Promise<any>, killProc
if (err.stdout) { if (err.stdout) {
console.error(err.stdout); console.error(err.stdout);
} else { } else if (err.message) {
// Return stack trace if error object
console.trace(err); // eslint-disable-line no-console console.trace(err); // eslint-disable-line no-console
} }
+1 -1
View File
@@ -74,7 +74,7 @@
"react-transition-group": "4.4.1", "react-transition-group": "4.4.1",
"slate": "0.47.8", "slate": "0.47.8",
"tinycolor2": "1.4.1", "tinycolor2": "1.4.1",
"uplot": "1.6.14" "uplot": "1.6.15"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "16.0.0", "@rollup/plugin-commonjs": "16.0.0",
@@ -59,6 +59,7 @@ export class MultiSelectValueEditor<T> extends React.PureComponent<Props<T>, Sta
const { settings } = item; const { settings } = item;
return ( return (
<MultiSelect<T> <MultiSelect<T>
menuShouldPortal
isLoading={isLoading} isLoading={isLoading}
value={value} value={value}
defaultValue={value} defaultValue={value}
@@ -62,6 +62,7 @@ export class SelectValueEditor<T> extends React.PureComponent<Props<T>, State<T>
} }
return ( return (
<Select<T> <Select<T>
menuShouldPortal
isLoading={isLoading} isLoading={isLoading}
value={current} value={current}
defaultValue={value} defaultValue={value}
@@ -63,7 +63,7 @@ export const PageToolbar: FC<Props> = React.memo(
/> />
</div> </div>
)} )}
<nav className={styles.navElement}> <nav aria-label="Search links" className={styles.navElement}>
{parent && parentHref && ( {parent && parentHref && (
<> <>
<Link <Link
@@ -6,7 +6,10 @@ LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(targetType="enum")
VizLegendOptions: { VizLegendOptions: {
displayMode: LegendDisplayMode displayMode: LegendDisplayMode
placement: LegendPlacement placement: LegendPlacement
asTable: bool | *false
isVisible: bool | *false
calcs: [...string] calcs: [...string]
} @cuetsy(targetType="interface") } @cuetsy(targetType="interface")
// TODO this excludes all the types that include function definitions // TODO this excludes all the types that include function definitions
@@ -436,7 +436,7 @@ describe('UPlotConfigBuilder', () => {
theme: darkTheme, theme: darkTheme,
}); });
expect(builder.getConfig().series[1].fill).toBe('rgba(255, 170, 187, 0.5)'); expect(builder.getConfig().series[1].fill).toBe('#FFAABB80');
}); });
it('when fillColor is set ignore fillOpacity', () => { it('when fillColor is set ignore fillOpacity', () => {
@@ -25,10 +25,6 @@ const cursorDefaults: Cursor = {
size: (u, seriesIdx) => u.series[seriesIdx].points.size * 2, size: (u, seriesIdx) => u.series[seriesIdx].points.size * 2,
/*@ts-ignore*/ /*@ts-ignore*/
width: (u, seriesIdx, size) => size / 4, width: (u, seriesIdx, size) => size / 4,
/*@ts-ignore*/
stroke: (u, seriesIdx) => u.series[seriesIdx].points.stroke(u, seriesIdx) + '80',
/*@ts-ignore*/
fill: (u, seriesIdx) => u.series[seriesIdx].points.stroke(u, seriesIdx),
}, },
focus: { focus: {
prox: 30, prox: 30,
@@ -50,6 +46,7 @@ export class UPlotConfigBuilder {
private hooks: Hooks.Arrays = {}; private hooks: Hooks.Arrays = {};
private tz: string | undefined = undefined; private tz: string | undefined = undefined;
private sync = false; private sync = false;
private frame: DataFrame | undefined = undefined;
// to prevent more than one threshold per scale // to prevent more than one threshold per scale
private thresholds: Record<string, UPlotThresholdOptions> = {}; private thresholds: Record<string, UPlotThresholdOptions> = {};
/** /**
@@ -158,7 +155,10 @@ export class UPlotConfigBuilder {
} }
setPrepData(prepData: PrepData) { setPrepData(prepData: PrepData) {
this.prepData = prepData; this.prepData = (frame) => {
this.frame = frame;
return prepData(frame);
};
} }
setSync() { setSync() {
@@ -187,7 +187,25 @@ export class UPlotConfigBuilder {
config.select = this.select; config.select = this.select;
config.cursor = merge({}, cursorDefaults, this.cursor); const pointColorFn = (alphaHex = '') => (u: uPlot, seriesIdx: number) => {
/*@ts-ignore*/
let s = u.series[seriesIdx].points._stroke;
// interpolate for gradients/thresholds
if (typeof s !== 'string') {
let field = this.frame!.fields[seriesIdx];
s = field.display!(field.values.get(u.cursor.idxs![seriesIdx]!)).color!;
}
return s + alphaHex;
};
config.cursor = merge({}, cursorDefaults, this.cursor, {
points: {
stroke: pointColorFn('80'),
fill: pointColorFn(),
},
});
config.tzDate = this.tzDate; config.tzDate = this.tzDate;
@@ -1,5 +1,11 @@
import { DataFrameFieldIndex, FALLBACK_COLOR, FieldColorMode, GrafanaTheme2, ThresholdsConfig } from '@grafana/data'; import {
import tinycolor from 'tinycolor2'; colorManipulator,
DataFrameFieldIndex,
FALLBACK_COLOR,
FieldColorMode,
GrafanaTheme2,
ThresholdsConfig,
} from '@grafana/data';
import uPlot, { Series } from 'uplot'; import uPlot, { Series } from 'uplot';
import { import {
BarAlignment, BarAlignment,
@@ -55,16 +61,18 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
} = this.props; } = this.props;
let lineConfig: Partial<Series> = {}; let lineConfig: Partial<Series> = {};
const lineColor = this.getLineColor();
let lineColor = this.getLineColor();
// DrawStyle.Points mode also needs this for fill/stroke sharing & re-use in series.points. see getColor() below.
lineConfig.stroke = lineColor;
if (pathBuilder != null) { if (pathBuilder != null) {
lineConfig.paths = pathBuilder; lineConfig.paths = pathBuilder;
lineConfig.stroke = lineColor;
lineConfig.width = lineWidth; lineConfig.width = lineWidth;
} else if (drawStyle === DrawStyle.Points) { } else if (drawStyle === DrawStyle.Points) {
lineConfig.paths = () => null; lineConfig.paths = () => null;
} else if (drawStyle != null) { } else if (drawStyle != null) {
lineConfig.stroke = lineColor;
lineConfig.width = lineWidth; lineConfig.width = lineWidth;
if (lineStyle && lineStyle.fill !== 'solid') { if (lineStyle && lineStyle.fill !== 'solid') {
if (lineStyle.fill === 'dot') { if (lineStyle.fill === 'dot') {
@@ -84,10 +92,14 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
}; };
} }
const useColor: uPlot.Series.Stroke =
// @ts-ignore
typeof lineColor === 'string' ? lineColor : (u, seriesIdx) => u.series[seriesIdx]._stroke;
const pointsConfig: Partial<Series> = { const pointsConfig: Partial<Series> = {
points: { points: {
stroke: lineColor, stroke: useColor,
fill: lineColor, fill: useColor,
size: pointSize, size: pointSize,
filter: pointsFilter, filter: pointsFilter,
}, },
@@ -153,7 +165,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
return getScaleGradientFn(opacityPercent, theme, colorMode, thresholds); return getScaleGradientFn(opacityPercent, theme, colorMode, thresholds);
default: default:
if (opacityPercent > 0) { if (opacityPercent > 0) {
return tinycolor(lineColor).setAlpha(opacityPercent).toString(); return colorManipulator.alpha(lineColor ?? '', opacityPercent);
} }
} }
@@ -1,6 +1,7 @@
import { getColorForTheme, GrafanaTheme2, ThresholdsConfig } from '@grafana/data'; import { GrafanaTheme2, ThresholdsConfig, ThresholdsMode } from '@grafana/data';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { GraphThresholdsStyleConfig, GraphTresholdsStyleMode } from '../config'; import { GraphThresholdsStyleConfig, GraphTresholdsStyleMode } from '../config';
import { getDataRange, GradientDirection, scaleGradient } from './gradientFills';
export interface UPlotThresholdOptions { export interface UPlotThresholdOptions {
scaleKey: string; scaleKey: string;
@@ -13,7 +14,6 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
return (u: uPlot) => { return (u: uPlot) => {
const ctx = u.ctx; const ctx = u.ctx;
const { scaleKey, thresholds, theme, config } = options; const { scaleKey, thresholds, theme, config } = options;
const { steps } = thresholds;
const { min: xMin, max: xMax } = u.scales.x; const { min: xMin, max: xMax } = u.scales.x;
const { min: yMin, max: yMax } = u.scales[scaleKey]; const { min: yMin, max: yMax } = u.scales[scaleKey];
@@ -21,6 +21,18 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
return; return;
} }
let { steps, mode } = thresholds;
if (mode === ThresholdsMode.Percentage) {
let [min, max] = getDataRange(u, scaleKey);
let range = max - min;
steps = steps.map((step) => ({
...step,
value: min + range * (step.value / 100),
}));
}
function addLines() { function addLines() {
// Thresholds below a transparent threshold is treated like "less than", and line drawn previous threshold // Thresholds below a transparent threshold is treated like "less than", and line drawn previous threshold
let transparentIndex = 0; let transparentIndex = 0;
@@ -40,9 +52,9 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
// if we are below a transparent index treat this a less then threshold, use previous thresholds color // if we are below a transparent index treat this a less then threshold, use previous thresholds color
if (transparentIndex >= idx && idx > 0) { if (transparentIndex >= idx && idx > 0) {
color = tinycolor(getColorForTheme(steps[idx - 1].color, theme.v1)); color = tinycolor(theme.visualization.getColorByName(steps[idx - 1].color));
} else { } else {
color = tinycolor(getColorForTheme(step.color, theme.v1)); color = tinycolor(theme.visualization.getColorByName(step.color));
} }
// Unless alpha specififed set to default value // Unless alpha specififed set to default value
@@ -50,10 +62,10 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
color.setAlpha(0.7); color.setAlpha(0.7);
} }
let x0 = u.valToPos(xMin!, 'x', true); let x0 = Math.round(u.valToPos(xMin!, 'x', true));
let y0 = u.valToPos(step.value, scaleKey, true); let y0 = Math.round(u.valToPos(step.value, scaleKey, true));
let x1 = u.valToPos(xMax!, 'x', true); let x1 = Math.round(u.valToPos(xMax!, 'x', true));
let y1 = u.valToPos(step.value, scaleKey, true); let y1 = Math.round(u.valToPos(step.value, scaleKey, true));
ctx.beginPath(); ctx.beginPath();
ctx.lineWidth = 2; ctx.lineWidth = 2;
@@ -66,49 +78,26 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
} }
function addAreas() { function addAreas() {
for (let idx = 0; idx < steps.length; idx++) { let grd = scaleGradient(
const step = steps[idx]; u,
u.series[1].scale!,
GradientDirection.Up,
steps.map((step) => {
let color = tinycolor(theme.visualization.getColorByName(step.color));
// skip thresholds that cannot be seen if (color.getAlpha() === 1) {
if (step.value > yMax!) { color.setAlpha(0.15);
continue; }
}
// if this is the last step make the next step the same color but +Infinity return [step.value, color.toString()];
const nextStep = }),
idx + 1 < steps.length true
? steps[idx + 1] );
: {
...step,
value: Infinity,
};
let color = tinycolor(getColorForTheme(step.color, theme.v1)); ctx.save();
ctx.fillStyle = grd;
// Ignore fully transparent colors ctx.fillRect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
const alpha = color.getAlpha(); ctx.restore();
if (alpha === 0) {
continue;
}
/// if no alpha set automatic alpha
if (alpha === 1) {
color = color.setAlpha(0.15);
}
let value = step.value === -Infinity ? yMin : step.value;
let nextValue = nextStep.value === Infinity || nextStep.value > yMax! ? yMax : nextStep.value;
let x0 = u.valToPos(xMin ?? 0, 'x', true);
let y0 = u.valToPos(value ?? 0, scaleKey, true);
let x1 = u.valToPos(xMax ?? 1, 'x', true);
let y1 = u.valToPos(nextValue ?? 1, scaleKey, true);
ctx.save();
ctx.fillStyle = color.toString();
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
ctx.restore();
}
} }
switch (config.mode) { switch (config.mode) {
@@ -1,4 +1,11 @@
import { FieldColorMode, FieldColorModeId, GrafanaTheme2, ThresholdsConfig, ThresholdsMode } from '@grafana/data'; import {
colorManipulator,
FieldColorMode,
FieldColorModeId,
GrafanaTheme2,
ThresholdsConfig,
ThresholdsMode,
} from '@grafana/data';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { getCanvasContext } from '../../../utils/measureText'; import { getCanvasContext } from '../../../utils/measureText';
@@ -11,8 +18,8 @@ export function getOpacityGradientFn(
const ctx = getCanvasContext(); const ctx = getCanvasContext();
const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height); const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height);
gradient.addColorStop(0, tinycolor(color).setAlpha(opacity).toRgbString()); gradient.addColorStop(0, colorManipulator.alpha(color, opacity));
gradient.addColorStop(1, tinycolor(color).setAlpha(0).toRgbString()); gradient.addColorStop(1, colorManipulator.alpha(color, 0));
return gradient; return gradient;
}; };
@@ -39,10 +46,132 @@ export function getHueGradientFn(
return gradient; return gradient;
}; };
} }
/**
* Experimental & quick and dirty test export enum GradientDirection {
* Not being used 'Right' = 0,
*/ 'Up' = 1,
}
type ValueStop = [value: number, color: string];
type ScaleValueStops = ValueStop[];
export function scaleGradient(
u: uPlot,
scaleKey: string,
dir: GradientDirection,
scaleStops: ScaleValueStops,
discrete = false
) {
let scale = u.scales[scaleKey];
// we want the stop below or at the scaleMax
// and the stop below or at the scaleMin, else the stop above scaleMin
let minStopIdx: number | null = null;
let maxStopIdx: number | null = null;
for (let i = 0; i < scaleStops.length; i++) {
let stopVal = scaleStops[i][0];
if (stopVal <= scale.min! || minStopIdx == null) {
minStopIdx = i;
}
maxStopIdx = i;
if (stopVal >= scale.max!) {
break;
}
}
if (minStopIdx === maxStopIdx) {
return scaleStops[minStopIdx!][1];
}
let minStopVal = scaleStops[minStopIdx!][0];
let maxStopVal = scaleStops[maxStopIdx!][0];
if (minStopVal === -Infinity) {
minStopVal = scale.min!;
}
if (maxStopVal === Infinity) {
maxStopVal = scale.max!;
}
let minStopPos = Math.round(u.valToPos(minStopVal, scaleKey, true));
let maxStopPos = Math.round(u.valToPos(maxStopVal, scaleKey, true));
let range = minStopPos - maxStopPos;
let x0, y0, x1, y1;
if (dir === GradientDirection.Up) {
x0 = x1 = 0;
y0 = minStopPos;
y1 = maxStopPos;
} else {
y0 = y1 = 0;
x0 = minStopPos;
x1 = maxStopPos;
}
let ctx = getCanvasContext();
let grd = ctx.createLinearGradient(x0, y0, x1, y1);
let prevColor: string;
for (let i = minStopIdx!; i <= maxStopIdx!; i++) {
let s = scaleStops[i];
let stopPos =
i === minStopIdx ? minStopPos : i === maxStopIdx ? maxStopPos : Math.round(u.valToPos(s[0], scaleKey, true));
let pct = (minStopPos - stopPos) / range;
if (discrete && i > minStopIdx!) {
grd.addColorStop(pct, prevColor!);
}
grd.addColorStop(pct, (prevColor = s[1]));
}
return grd;
}
export function getDataRange(plot: uPlot, scaleKey: string) {
let sc = plot.scales[scaleKey];
let min = Infinity;
let max = -Infinity;
plot.series.forEach((ser, seriesIdx) => {
if (ser.show && ser.scale === scaleKey) {
// uPlot skips finding data min/max when a scale has a pre-defined range
if (ser.min == null) {
let data = plot.data[seriesIdx];
for (let i = 0; i < data.length; i++) {
if (data[i] != null) {
min = Math.min(min, data[i]!);
max = Math.max(max, data[i]!);
}
}
} else {
min = Math.min(min, ser.min!);
max = Math.max(max, ser.max!);
}
}
});
if (max === min) {
min = sc.min!;
max = sc.max!;
}
return [min, max];
}
export function getScaleGradientFn( export function getScaleGradientFn(
opacity: number, opacity: number,
theme: GrafanaTheme2, theme: GrafanaTheme2,
@@ -58,68 +187,41 @@ export function getScaleGradientFn(
} }
return (plot: uPlot, seriesIdx: number) => { return (plot: uPlot, seriesIdx: number) => {
// A uplot bug (I think) where this is called before there is bbox let scaleKey = plot.series[seriesIdx].scale!;
// Color used for cursor highlight, not sure what to do here as this is called before we have bbox
// and only once so same color is used for all points
if (plot.bbox.top == null) {
return theme.colors.text.primary;
}
const ctx = getCanvasContext(); let gradient: CanvasGradient | string = '';
const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height);
const canvasHeight = plot.bbox.height;
const series = plot.series[seriesIdx];
const scale = plot.scales[series.scale!];
const scaleMin = scale.min ?? 0;
const scaleMax = scale.max ?? 100;
const scaleRange = scaleMax - scaleMin;
const addColorStop = (value: number, color: string) => {
const pos = plot.valToPos(value, series.scale!, true);
// when above range we get negative values here
if (pos < 0) {
return;
}
const percent = Math.max(pos / canvasHeight, 0);
const realColor = tinycolor(theme.visualization.getColorByName(color)).setAlpha(opacity).toString();
const colorStopPos = Math.min(percent, 1);
gradient.addColorStop(colorStopPos, realColor);
};
if (colorMode.id === FieldColorModeId.Thresholds) { if (colorMode.id === FieldColorModeId.Thresholds) {
for (let idx = 0; idx < thresholds.steps.length; idx++) { if (thresholds.mode === ThresholdsMode.Absolute) {
const step = thresholds.steps[idx]; const valueStops = thresholds.steps.map(
(step) =>
if (thresholds.mode === ThresholdsMode.Absolute) { [step.value, colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity)] as ValueStop
const value = step.value === -Infinity ? scaleMin : step.value; );
addColorStop(value, step.color); gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true);
} else {
if (thresholds.steps.length > idx + 1) { const [min, max] = getDataRange(plot, scaleKey);
// to make the gradient discrete const range = max - min;
addColorStop(thresholds.steps[idx + 1].value - 0.00000001, step.color); const valueStops = thresholds.steps.map(
} (step) =>
} else { [
const percent = step.value === -Infinity ? 0 : step.value; min + range * (step.value / 100),
const realValue = (percent / 100) * scaleRange; colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity),
addColorStop(realValue, step.color); ] as ValueStop
);
// to make the gradient discrete gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true);
if (thresholds.steps.length > idx + 1) {
// to make the gradient discrete
const nextValue = (thresholds.steps[idx + 1].value / 100) * scaleRange - 0.0000001;
addColorStop(nextValue, step.color);
}
}
} }
} else if (colorMode.getColors) { } else if (colorMode.getColors) {
const colors = colorMode.getColors(theme); const colors = colorMode.getColors(theme);
const stepValue = (scaleMax - scaleMin) / colors.length; const [min, max] = getDataRange(plot, scaleKey);
const range = max - min;
for (let idx = 0; idx < colors.length; idx++) { const valueStops = colors.map(
addColorStop(scaleMin + stepValue * idx, colors[idx]); (color, i) =>
} [
min + range * (i / (colors.length - 1)),
colorManipulator.alpha(theme.visualization.getColorByName(color), opacity),
] as ValueStop
);
gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, false);
} }
return gradient; return gradient;
@@ -17,7 +17,7 @@ LineConfig: {
lineWidth?: number lineWidth?: number
lineInterpolation?: LineInterpolation lineInterpolation?: LineInterpolation
lineStyle?: LineStyle lineStyle?: LineStyle
spanNulls?: bool spanNulls?: bool | number
} @cuetsy(targetType="interface") } @cuetsy(targetType="interface")
FillConfig: { FillConfig: {
@@ -29,15 +29,6 @@ interface TooltipPluginProps {
renderTooltip?: (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => React.ReactNode; renderTooltip?: (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => React.ReactNode;
} }
const eqArrays = (a: any[], b: any[]) => {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const TOOLTIP_OFFSET = 10; const TOOLTIP_OFFSET = 10;
/** /**
@@ -123,16 +114,13 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
})(u); })(u);
}); });
} else { } else {
let prevIdx: number | null = null; config.addHook('setLegend', (u) => {
let prevIdxs: Array<number | null> | null = null; setFocusedPointIdx(u.legend.idx!);
setFocusedPointIdxs(u.legend.idxs!.slice());
});
// default series/datapoint idx retireval // default series/datapoint idx retireval
config.addHook('setCursor', (u) => { config.addHook('setCursor', (u) => {
if (u.cursor.idx !== prevIdx || prevIdxs == null || !eqArrays(prevIdxs, u.cursor.idxs!)) {
setFocusedPointIdx((prevIdx = u.cursor.idx!));
setFocusedPointIdxs((prevIdxs = u.cursor.idxs!.slice()));
}
const bbox = plotCtx.getCanvasBoundingBox(); const bbox = plotCtx.getCanvasBoundingBox();
if (!bbox) { if (!bbox) {
return; return;
@@ -438,6 +438,7 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
)} )}
</span> </span>
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small> <small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
<small className={styles.endpointName}> | {label}</small>
</a> </a>
{createSpanLink && {createSpanLink &&
(() => { (() => {
+1 -1
View File
@@ -477,7 +477,7 @@ func (hs *HTTPServer) CheckDatasourceHealth(c *models.ReqContext) response.Respo
} }
if resp.Status != backend.HealthStatusOk { if resp.Status != backend.HealthStatusOk {
return response.JSON(503, payload) return response.JSON(400, payload)
} }
return response.JSON(200, payload) return response.JSON(200, payload)
+8 -2
View File
@@ -64,6 +64,12 @@ func runPluginCommand(command func(commandLine utils.CommandLine) error) func(co
} }
} }
func runCueCommand(command func(commandLine utils.CommandLine) error) func(context *cli.Context) error {
return func(context *cli.Context) error {
return command(&utils.ContextCommandLine{Context: context})
}
}
// Command contains command state. // Command contains command state.
type Command struct { type Command struct {
Client utils.ApiClient Client utils.ApiClient
@@ -138,7 +144,7 @@ var cueCommands = []*cli.Command{
{ {
Name: "validate-schema", Name: "validate-schema",
Usage: "validate known *.cue files in the Grafana project", Usage: "validate known *.cue files in the Grafana project",
Action: runPluginCommand(cmd.validateScuemata), Action: runCueCommand(cmd.validateScuemata),
Description: `validate-schema checks that all CUE schema files are valid with respect Description: `validate-schema checks that all CUE schema files are valid with respect
to basic standards - valid CUE, valid scuemata, etc. Note that this to basic standards - valid CUE, valid scuemata, etc. Note that this
command checks only paths that existed when grafana-cli was compiled, command checks only paths that existed when grafana-cli was compiled,
@@ -153,7 +159,7 @@ so must be recompiled to validate newly-added CUE files.`,
{ {
Name: "validate-resource", Name: "validate-resource",
Usage: "validate resource files (e.g. dashboard JSON) against schema", Usage: "validate resource files (e.g. dashboard JSON) against schema",
Action: runPluginCommand(cmd.validateResources), Action: runCueCommand(cmd.validateResources),
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "dashboard", Name: "dashboard",
+1
View File
@@ -18,6 +18,7 @@ import (
_ "github.com/grpc-ecosystem/go-grpc-middleware" _ "github.com/grpc-ecosystem/go-grpc-middleware"
_ "github.com/jung-kurt/gofpdf" _ "github.com/jung-kurt/gofpdf"
_ "github.com/linkedin/goavro/v2" _ "github.com/linkedin/goavro/v2"
_ "github.com/m3db/prometheus_remote_client_golang/promremote"
_ "github.com/pkg/errors" _ "github.com/pkg/errors"
_ "github.com/robfig/cron" _ "github.com/robfig/cron"
_ "github.com/robfig/cron/v3" _ "github.com/robfig/cron/v3"
-1
View File
@@ -58,7 +58,6 @@ func Gziper() func(http.Handler) http.Handler {
for _, pathMatcher := range gzipIgnoredPaths { for _, pathMatcher := range gzipIgnoredPaths {
if pathMatcher(requestPath) { if pathMatcher(requestPath) {
fmt.Println("skip path", requestPath)
next.ServeHTTP(rw, req) next.ServeHTTP(rw, req)
return return
} }
-1
View File
@@ -299,7 +299,6 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
} }
body := map[string]interface{}{ body := map[string]interface{}{
"channel": sn.recipient, "channel": sn.recipient,
"text": evalContext.GetNotificationTitle(),
"attachments": []map[string]interface{}{ "attachments": []map[string]interface{}{
attachment, attachment,
}, },
+16 -7
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"net/url"
"time" "time"
"github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/api/routing"
@@ -20,10 +21,17 @@ import (
// timeNow makes it possible to test usage of time // timeNow makes it possible to test usage of time
var timeNow = time.Now var timeNow = time.Now
type Scheduler interface {
AlertmanagersFor(orgID int64) []*url.URL
DroppedAlertmanagersFor(orgID int64) []*url.URL
}
type Alertmanager interface { type Alertmanager interface {
// Configuration // Configuration
SaveAndApplyConfig(config *apimodels.PostableUserConfig) error // temporary add orgID parameter; this will move to the Alertmanager wrapper when it will be available
SaveAndApplyDefaultConfig() error SaveAndApplyConfig(orgID int64, config *apimodels.PostableUserConfig) error
// temporary add orgID parameter; this will move to the Alertmanager wrapper when it will be available
SaveAndApplyDefaultConfig(orgID int64) error
GetStatus() apimodels.GettableStatus GetStatus() apimodels.GettableStatus
// Silences // Silences
@@ -61,19 +69,19 @@ func (api *API) RegisterAPIEndpoints(m *metrics.Metrics) {
DataProxy: api.DataProxy, DataProxy: api.DataProxy,
} }
// Register endpoints for proxing to Alertmanager-compatible backends. // Register endpoints for proxying to Alertmanager-compatible backends.
api.RegisterAlertmanagerApiEndpoints(NewForkedAM( api.RegisterAlertmanagerApiEndpoints(NewForkedAM(
api.DatasourceCache, api.DatasourceCache,
NewLotexAM(proxy, logger), NewLotexAM(proxy, logger),
AlertmanagerSrv{store: api.AlertingStore, am: api.Alertmanager, log: logger}, AlertmanagerSrv{store: api.AlertingStore, am: api.Alertmanager, log: logger},
), m) ), m)
// Register endpoints for proxing to Prometheus-compatible backends. // Register endpoints for proxying to Prometheus-compatible backends.
api.RegisterPrometheusApiEndpoints(NewForkedProm( api.RegisterPrometheusApiEndpoints(NewForkedProm(
api.DatasourceCache, api.DatasourceCache,
NewLotexProm(proxy, logger), NewLotexProm(proxy, logger),
PrometheusSrv{log: logger, manager: api.StateManager, store: api.RuleStore}, PrometheusSrv{log: logger, manager: api.StateManager, store: api.RuleStore},
), m) ), m)
// Register endpoints for proxing to Cortex Ruler-compatible backends. // Register endpoints for proxying to Cortex Ruler-compatible backends.
api.RegisterRulerApiEndpoints(NewForkedRuler( api.RegisterRulerApiEndpoints(NewForkedRuler(
api.DatasourceCache, api.DatasourceCache,
NewLotexRuler(proxy, logger), NewLotexRuler(proxy, logger),
@@ -87,7 +95,8 @@ func (api *API) RegisterAPIEndpoints(m *metrics.Metrics) {
log: logger, log: logger,
}, m) }, m)
api.RegisterConfigurationApiEndpoints(AdminSrv{ api.RegisterConfigurationApiEndpoints(AdminSrv{
store: api.AdminConfigStore, store: api.AdminConfigStore,
log: logger, log: logger,
scheduler: api.Schedule,
}, m) }, m)
} }
+25 -4
View File
@@ -10,11 +10,32 @@ import (
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/util"
v1 "github.com/prometheus/client_golang/api/prometheus/v1"
) )
type AdminSrv struct { type AdminSrv struct {
store store.AdminConfigurationStore scheduler Scheduler
log log.Logger store store.AdminConfigurationStore
log log.Logger
}
func (srv AdminSrv) RouteGetAlertmanagers(c *models.ReqContext) response.Response {
urls := srv.scheduler.AlertmanagersFor(c.OrgId)
droppedURLs := srv.scheduler.DroppedAlertmanagersFor(c.OrgId)
ams := v1.AlertManagersResult{Active: make([]v1.AlertManager, len(urls)), Dropped: make([]v1.AlertManager, len(droppedURLs))}
for i, url := range urls {
ams.Active[i].URL = url.String()
}
for i, url := range droppedURLs {
ams.Dropped[i].URL = url.String()
}
return response.JSON(http.StatusOK, apimodels.GettableAlertmanagers{
Status: "success",
Data: ams,
})
} }
func (srv AdminSrv) RouteGetNGalertConfig(c *models.ReqContext) response.Response { func (srv AdminSrv) RouteGetNGalertConfig(c *models.ReqContext) response.Response {
@@ -56,7 +77,7 @@ func (srv AdminSrv) RoutePostNGalertConfig(c *models.ReqContext, body apimodels.
return ErrResp(http.StatusBadRequest, err, msg) return ErrResp(http.StatusBadRequest, err, msg)
} }
return response.JSON(http.StatusCreated, "admin configuration updated") return response.JSON(http.StatusCreated, util.DynMap{"message": "admin configuration updated"})
} }
func (srv AdminSrv) RouteDeleteNGalertConfig(c *models.ReqContext) response.Response { func (srv AdminSrv) RouteDeleteNGalertConfig(c *models.ReqContext) response.Response {
@@ -70,5 +91,5 @@ func (srv AdminSrv) RouteDeleteNGalertConfig(c *models.ReqContext) response.Resp
return ErrResp(http.StatusInternalServerError, err, "") return ErrResp(http.StatusInternalServerError, err, "")
} }
return response.JSON(http.StatusOK, "admin configuration deleted") return response.JSON(http.StatusOK, util.DynMap{"message": "admin configuration deleted"})
} }
+5 -4
View File
@@ -48,7 +48,7 @@ func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *models.ReqContext) respo
if !c.HasUserRole(models.ROLE_EDITOR) { if !c.HasUserRole(models.ROLE_EDITOR) {
return ErrResp(http.StatusForbidden, errors.New("permission denied"), "") return ErrResp(http.StatusForbidden, errors.New("permission denied"), "")
} }
if err := srv.am.SaveAndApplyDefaultConfig(); err != nil { if err := srv.am.SaveAndApplyDefaultConfig(c.OrgId); err != nil {
srv.log.Error("unable to save and apply default alertmanager configuration", "err", err) srv.log.Error("unable to save and apply default alertmanager configuration", "err", err)
return ErrResp(http.StatusInternalServerError, err, "failed to save and apply default Alertmanager configuration") return ErrResp(http.StatusInternalServerError, err, "failed to save and apply default Alertmanager configuration")
} }
@@ -74,7 +74,8 @@ func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *models.ReqContext) response
if !c.HasUserRole(models.ROLE_EDITOR) { if !c.HasUserRole(models.ROLE_EDITOR) {
return ErrResp(http.StatusForbidden, errors.New("permission denied"), "") return ErrResp(http.StatusForbidden, errors.New("permission denied"), "")
} }
query := ngmodels.GetLatestAlertmanagerConfigurationQuery{}
query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: c.OrgId}
if err := srv.store.GetLatestAlertmanagerConfiguration(&query); err != nil { if err := srv.store.GetLatestAlertmanagerConfiguration(&query); err != nil {
if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
return ErrResp(http.StatusNotFound, err, "") return ErrResp(http.StatusNotFound, err, "")
@@ -201,7 +202,7 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap
} }
// Get the last known working configuration // Get the last known working configuration
query := ngmodels.GetLatestAlertmanagerConfigurationQuery{} query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: c.OrgId}
if err := srv.store.GetLatestAlertmanagerConfiguration(&query); err != nil { 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 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) { if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
@@ -255,7 +256,7 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap
return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration") return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration")
} }
if err := srv.am.SaveAndApplyConfig(&body); err != nil { if err := srv.am.SaveAndApplyConfig(c.OrgId, &body); err != nil {
srv.log.Error("unable to save and apply alertmanager configuration", "err", err) srv.log.Error("unable to save and apply alertmanager configuration", "err", err)
return ErrResp(http.StatusBadRequest, err, "failed to save and apply Alertmanager configuration") return ErrResp(http.StatusBadRequest, err, "failed to save and apply Alertmanager configuration")
} }
@@ -21,6 +21,7 @@ import (
type ConfigurationApiService interface { type ConfigurationApiService interface {
RouteDeleteNGalertConfig(*models.ReqContext) response.Response RouteDeleteNGalertConfig(*models.ReqContext) response.Response
RouteGetAlertmanagers(*models.ReqContext) response.Response
RouteGetNGalertConfig(*models.ReqContext) response.Response RouteGetNGalertConfig(*models.ReqContext) response.Response
RoutePostNGalertConfig(*models.ReqContext, apimodels.PostableNGalertConfig) response.Response RoutePostNGalertConfig(*models.ReqContext, apimodels.PostableNGalertConfig) response.Response
} }
@@ -36,6 +37,15 @@ func (api *API) RegisterConfigurationApiEndpoints(srv ConfigurationApiService, m
m, m,
), ),
) )
group.Get(
toMacaronPath("/api/v1/ngalert/alertmanagers"),
metrics.Instrument(
http.MethodGet,
"/api/v1/ngalert/alertmanagers",
srv.RouteGetAlertmanagers,
m,
),
)
group.Get( group.Get(
toMacaronPath("/api/v1/ngalert/admin_config"), toMacaronPath("/api/v1/ngalert/admin_config"),
metrics.Instrument( metrics.Instrument(
@@ -16,6 +16,12 @@ spec.json: $(GO_PKG_FILES)
quay.io/goswagger/swagger:$(SWAGGER_TAG) \ quay.io/goswagger/swagger:$(SWAGGER_TAG) \
generate spec -m -o $@ generate spec -m -o $@
ensure_go-swagger_mac:
@hash swagger &>/dev/null || (brew tap go-swagger/go-swagger && brew install go-swagger)
spec.json-mac: ensure_go-swagger_mac $(GO_PKG_FILES)
swagger generate spec -m -w $(API_DIR) -o spec.json
post.json: spec.json post.json: spec.json
go run cmd/clean-swagger/main.go -if $(<) -of $@ go run cmd/clean-swagger/main.go -if $(<) -of $@
@@ -1,5 +1,17 @@
package definitions package definitions
import v1 "github.com/prometheus/client_golang/api/prometheus/v1"
// swagger:route GET /api/v1/ngalert/alertmanagers configuration RouteGetAlertmanagers
//
// Get the discovered and dropped Alertmanagers of the user's organization based on the specified configuration.
//
// Produces:
// - application/json
//
// Responses:
// 200: GettableAlertmanagers
// swagger:route GET /api/v1/ngalert/admin_config configuration RouteGetNGalertConfig // swagger:route GET /api/v1/ngalert/admin_config configuration RouteGetNGalertConfig
// //
// Get the NGalert configuration of the user's organization, returns 404 if no configuration is present. // Get the NGalert configuration of the user's organization, returns 404 if no configuration is present.
@@ -49,3 +61,9 @@ type PostableNGalertConfig struct {
type GettableNGalertConfig struct { type GettableNGalertConfig struct {
Alertmanagers []string `json:"alertmanagers"` Alertmanagers []string `json:"alertmanagers"`
} }
// swagger:model
type GettableAlertmanagers struct {
Status string `json:"status"`
Data v1.AlertManagersResult `json:"data"`
}
@@ -54,7 +54,7 @@ import (
// get alertmanager alerts // get alertmanager alerts
// //
// Responses: // Responses:
// 200: GettableAlerts // 200: gettableAlerts
// 400: ValidationError // 400: ValidationError
// swagger:route POST /api/alertmanager/{Recipient}/api/v2/alerts alertmanager RoutePostAMAlerts // swagger:route POST /api/alertmanager/{Recipient}/api/v2/alerts alertmanager RoutePostAMAlerts
@@ -70,7 +70,7 @@ import (
// get alertmanager alerts // get alertmanager alerts
// //
// Responses: // Responses:
// 200: AlertGroups // 200: alertGroups
// 400: ValidationError // 400: ValidationError
// swagger:route GET /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteGetSilences // swagger:route GET /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteGetSilences
@@ -78,7 +78,7 @@ import (
// get silences // get silences
// //
// Responses: // Responses:
// 200: GettableSilences // 200: gettableSilences
// 400: ValidationError // 400: ValidationError
// swagger:route POST /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteCreateSilence // swagger:route POST /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteCreateSilence
@@ -86,7 +86,7 @@ import (
// create silence // create silence
// //
// Responses: // Responses:
// 201: GettableSilence // 201: gettableSilence
// 400: ValidationError // 400: ValidationError
// swagger:route GET /api/alertmanager/{Recipient}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence // swagger:route GET /api/alertmanager/{Recipient}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence
@@ -94,7 +94,7 @@ import (
// get silence // get silence
// //
// Responses: // Responses:
// 200: GettableSilence // 200: gettableSilence
// 400: ValidationError // 400: ValidationError
// swagger:route DELETE /api/alertmanager/{Recipient}/api/v2/silence/{SilenceId} alertmanager RouteDeleteSilence // swagger:route DELETE /api/alertmanager/{Recipient}/api/v2/silence/{SilenceId} alertmanager RouteDeleteSilence
@@ -196,28 +196,28 @@ func NewGettableStatus(cfg *PostableApiAlertingConfig) *GettableStatus {
} }
} }
// swagger:model // swagger:model postableSilence
type PostableSilence = amv2.PostableSilence type PostableSilence = amv2.PostableSilence
// swagger:model // swagger:model gettableSilences
type GettableSilences = amv2.GettableSilences type GettableSilences = amv2.GettableSilences
// swagger:model // swagger:model gettableSilence
type GettableSilence = amv2.GettableSilence type GettableSilence = amv2.GettableSilence
// swagger:model // swagger:model gettableAlerts
type GettableAlerts = amv2.GettableAlerts type GettableAlerts = amv2.GettableAlerts
// swagger:model // swagger:model gettableAlert
type GettableAlert = amv2.GettableAlert type GettableAlert = amv2.GettableAlert
// swagger:model // swagger:model alertGroups
type AlertGroups = amv2.AlertGroups type AlertGroups = amv2.AlertGroups
// swagger:model // swagger:model alertGroup
type AlertGroup = amv2.AlertGroup type AlertGroup = amv2.AlertGroup
// swagger:model // swagger:model receiver
type Receiver = amv2.Receiver type Receiver = amv2.Receiver
// swagger:parameters RouteGetAMAlerts RouteGetAMAlertGroups // swagger:parameters RouteGetAMAlerts RouteGetAMAlertGroups
+82 -42
View File
@@ -57,12 +57,6 @@
"type": "object", "type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"AlertGroup": {
"$ref": "#/definitions/alertGroup"
},
"AlertGroups": {
"$ref": "#/definitions/alertGroups"
},
"AlertInstancesResponse": { "AlertInstancesResponse": {
"properties": { "properties": {
"instances": { "instances": {
@@ -81,6 +75,38 @@
"type": "object", "type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"AlertManager": {
"properties": {
"url": {
"type": "string",
"x-go-name": "URL"
}
},
"title": "AlertManager models a configured Alert Manager.",
"type": "object",
"x-go-package": "github.com/prometheus/client_golang/api/prometheus/v1"
},
"AlertManagersResult": {
"properties": {
"activeAlertManagers": {
"items": {
"$ref": "#/definitions/AlertManager"
},
"type": "array",
"x-go-name": "Active"
},
"droppedAlertManagers": {
"items": {
"$ref": "#/definitions/AlertManager"
},
"type": "array",
"x-go-name": "Dropped"
}
},
"title": "AlertManagersResult contains the result from querying the alertmanagers endpoint.",
"type": "object",
"x-go-package": "github.com/prometheus/client_golang/api/prometheus/v1"
},
"AlertQuery": { "AlertQuery": {
"properties": { "properties": {
"datasourceUid": { "datasourceUid": {
@@ -477,11 +503,18 @@
"Failure": { "Failure": {
"$ref": "#/definitions/ResponseDetails" "$ref": "#/definitions/ResponseDetails"
}, },
"GettableAlert": { "GettableAlertmanagers": {
"$ref": "#/definitions/gettableAlert" "properties": {
}, "data": {
"GettableAlerts": { "$ref": "#/definitions/AlertManagersResult"
"$ref": "#/definitions/gettableAlerts" },
"status": {
"type": "string",
"x-go-name": "Status"
}
},
"type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"GettableApiAlertingConfig": { "GettableApiAlertingConfig": {
"properties": { "properties": {
@@ -786,8 +819,6 @@
"type": "object", "type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"GettableSilence": {},
"GettableSilences": {},
"GettableStatus": { "GettableStatus": {
"properties": { "properties": {
"cluster": { "cluster": {
@@ -1589,7 +1620,6 @@
"type": "object", "type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"PostableSilence": {},
"PostableUserConfig": { "PostableUserConfig": {
"properties": { "properties": {
"alertmanager_config": { "alertmanager_config": {
@@ -1724,7 +1754,8 @@
} }
}, },
"title": "Receiver configuration provides configuration on how to contact a receiver.", "title": "Receiver configuration provides configuration on how to contact a receiver.",
"type": "object" "type": "object",
"x-go-package": "github.com/prometheus/alertmanager/config"
}, },
"Regexp": { "Regexp": {
"description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.", "description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.",
@@ -2458,7 +2489,7 @@
"$ref": "#/definitions/labelSet" "$ref": "#/definitions/labelSet"
}, },
"receiver": { "receiver": {
"$ref": "#/definitions/Receiver" "$ref": "#/definitions/receiver"
} }
}, },
"required": [ "required": [
@@ -2466,18 +2497,14 @@
"labels", "labels",
"receiver" "receiver"
], ],
"type": "object", "type": "object"
"x-go-name": "AlertGroup",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups", "description": "AlertGroups alert groups",
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
}, },
"type": "array", "type": "array"
"x-go-name": "AlertGroups",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"alertStatus": { "alertStatus": {
"description": "AlertStatus alert status", "description": "AlertStatus alert status",
@@ -2597,7 +2624,6 @@
"$ref": "#/definitions/Duration" "$ref": "#/definitions/Duration"
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": { "properties": {
"annotations": { "annotations": {
"$ref": "#/definitions/labelSet" "$ref": "#/definitions/labelSet"
@@ -2625,7 +2651,7 @@
"receivers": { "receivers": {
"description": "receivers", "description": "receivers",
"items": { "items": {
"$ref": "#/definitions/Receiver" "$ref": "#/definitions/receiver"
}, },
"type": "array", "type": "array",
"x-go-name": "Receivers" "x-go-name": "Receivers"
@@ -2661,7 +2687,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": { "items": {
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
}, },
@@ -2722,14 +2747,11 @@
"status", "status",
"updatedAt" "updatedAt"
], ],
"type": "object", "type": "object"
"x-go-name": "GettableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"gettableSilences": { "gettableSilences": {
"description": "GettableSilences gettable silences",
"items": { "items": {
"$ref": "#/definitions/GettableSilence" "$ref": "#/definitions/gettableSilence"
}, },
"type": "array", "type": "array",
"x-go-name": "GettableSilences", "x-go-name": "GettableSilences",
@@ -2862,7 +2884,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"postableSilence": { "postableSilence": {
"description": "PostableSilence postable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@@ -2907,7 +2928,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"properties": { "properties": {
"name": { "name": {
"description": "name", "description": "name",
@@ -3090,9 +3110,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "GettableAlerts", "description": "gettableAlerts",
"schema": { "schema": {
"$ref": "#/definitions/GettableAlerts" "$ref": "#/definitions/gettableAlerts"
} }
}, },
"400": { "400": {
@@ -3203,9 +3223,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "AlertGroups", "description": "alertGroups",
"schema": { "schema": {
"$ref": "#/definitions/AlertGroups" "$ref": "#/definitions/alertGroups"
} }
}, },
"400": { "400": {
@@ -3277,9 +3297,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "GettableSilence", "description": "gettableSilence",
"schema": { "schema": {
"$ref": "#/definitions/GettableSilence" "$ref": "#/definitions/gettableSilence"
} }
}, },
"400": { "400": {
@@ -3318,9 +3338,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "GettableSilences", "description": "gettableSilences",
"schema": { "schema": {
"$ref": "#/definitions/GettableSilences" "$ref": "#/definitions/gettableSilences"
} }
}, },
"400": { "400": {
@@ -3355,9 +3375,9 @@
], ],
"responses": { "responses": {
"201": { "201": {
"description": "GettableSilence", "description": "gettableSilence",
"schema": { "schema": {
"$ref": "#/definitions/GettableSilence" "$ref": "#/definitions/gettableSilence"
} }
}, },
"400": { "400": {
@@ -3888,6 +3908,26 @@
] ]
} }
}, },
"/api/v1/ngalert/alertmanagers": {
"get": {
"operationId": "RouteGetAlertmanagers",
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "GettableAlertmanagers",
"schema": {
"$ref": "#/definitions/GettableAlertmanagers"
}
}
},
"summary": "Get the discovered and dropped Alertmanagers of the user's organization based on the specified configuration.",
"tags": [
"configuration"
]
}
},
"/api/v1/receiver/test/{Recipient}": { "/api/v1/receiver/test/{Recipient}": {
"post": { "post": {
"consumes": [ "consumes": [
+91 -50
View File
@@ -75,9 +75,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "GettableAlerts", "description": "gettableAlerts",
"schema": { "schema": {
"$ref": "#/definitions/GettableAlerts" "$ref": "#/definitions/gettableAlerts"
} }
}, },
"400": { "400": {
@@ -188,9 +188,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "AlertGroups", "description": "alertGroups",
"schema": { "schema": {
"$ref": "#/definitions/AlertGroups" "$ref": "#/definitions/alertGroups"
} }
}, },
"400": { "400": {
@@ -226,9 +226,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "GettableSilence", "description": "gettableSilence",
"schema": { "schema": {
"$ref": "#/definitions/GettableSilence" "$ref": "#/definitions/gettableSilence"
} }
}, },
"400": { "400": {
@@ -303,9 +303,9 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "GettableSilences", "description": "gettableSilences",
"schema": { "schema": {
"$ref": "#/definitions/GettableSilences" "$ref": "#/definitions/gettableSilences"
} }
}, },
"400": { "400": {
@@ -340,9 +340,9 @@
], ],
"responses": { "responses": {
"201": { "201": {
"description": "GettableSilence", "description": "gettableSilence",
"schema": { "schema": {
"$ref": "#/definitions/GettableSilence" "$ref": "#/definitions/gettableSilence"
} }
}, },
"400": { "400": {
@@ -870,6 +870,26 @@
} }
} }
}, },
"/api/v1/ngalert/alertmanagers": {
"get": {
"produces": [
"application/json"
],
"tags": [
"configuration"
],
"summary": "Get the discovered and dropped Alertmanagers of the user's organization based on the specified configuration.",
"operationId": "RouteGetAlertmanagers",
"responses": {
"200": {
"description": "GettableAlertmanagers",
"schema": {
"$ref": "#/definitions/GettableAlertmanagers"
}
}
}
}
},
"/api/v1/receiver/test/{Recipient}": { "/api/v1/receiver/test/{Recipient}": {
"post": { "post": {
"description": "Test receiver", "description": "Test receiver",
@@ -1015,12 +1035,6 @@
}, },
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"AlertGroup": {
"$ref": "#/definitions/alertGroup"
},
"AlertGroups": {
"$ref": "#/definitions/alertGroups"
},
"AlertInstancesResponse": { "AlertInstancesResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1039,6 +1053,38 @@
}, },
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"AlertManager": {
"type": "object",
"title": "AlertManager models a configured Alert Manager.",
"properties": {
"url": {
"type": "string",
"x-go-name": "URL"
}
},
"x-go-package": "github.com/prometheus/client_golang/api/prometheus/v1"
},
"AlertManagersResult": {
"type": "object",
"title": "AlertManagersResult contains the result from querying the alertmanagers endpoint.",
"properties": {
"activeAlertManagers": {
"type": "array",
"items": {
"$ref": "#/definitions/AlertManager"
},
"x-go-name": "Active"
},
"droppedAlertManagers": {
"type": "array",
"items": {
"$ref": "#/definitions/AlertManager"
},
"x-go-name": "Dropped"
}
},
"x-go-package": "github.com/prometheus/client_golang/api/prometheus/v1"
},
"AlertQuery": { "AlertQuery": {
"type": "object", "type": "object",
"title": "AlertQuery represents a single query associated with an alert definition.", "title": "AlertQuery represents a single query associated with an alert definition.",
@@ -1438,11 +1484,18 @@
"Failure": { "Failure": {
"$ref": "#/definitions/ResponseDetails" "$ref": "#/definitions/ResponseDetails"
}, },
"GettableAlert": { "GettableAlertmanagers": {
"$ref": "#/definitions/gettableAlert" "type": "object",
}, "properties": {
"GettableAlerts": { "data": {
"$ref": "#/definitions/gettableAlerts" "$ref": "#/definitions/AlertManagersResult"
},
"status": {
"type": "string",
"x-go-name": "Status"
}
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"GettableApiAlertingConfig": { "GettableApiAlertingConfig": {
"type": "object", "type": "object",
@@ -1747,12 +1800,6 @@
}, },
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"GettableSilence": {
"$ref": "#/definitions/GettableSilence"
},
"GettableSilences": {
"$ref": "#/definitions/GettableSilences"
},
"GettableStatus": { "GettableStatus": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2555,9 +2602,6 @@
}, },
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
}, },
"PostableSilence": {
"$ref": "#/definitions/PostableSilence"
},
"PostableUserConfig": { "PostableUserConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -2693,7 +2737,7 @@
"x-go-name": "WechatConfigs" "x-go-name": "WechatConfigs"
} }
}, },
"$ref": "#/definitions/Receiver" "x-go-package": "github.com/prometheus/alertmanager/config"
}, },
"Regexp": { "Regexp": {
"description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.", "description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.",
@@ -3433,11 +3477,10 @@
"$ref": "#/definitions/labelSet" "$ref": "#/definitions/labelSet"
}, },
"receiver": { "receiver": {
"$ref": "#/definitions/Receiver" "$ref": "#/definitions/receiver"
} }
}, },
"x-go-name": "AlertGroup", "$ref": "#/definitions/alertGroup"
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups", "description": "AlertGroups alert groups",
@@ -3445,8 +3488,7 @@
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
}, },
"x-go-name": "AlertGroups", "$ref": "#/definitions/alertGroups"
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"alertStatus": { "alertStatus": {
"description": "AlertStatus alert status", "description": "AlertStatus alert status",
@@ -3566,7 +3608,6 @@
"$ref": "#/definitions/Duration" "$ref": "#/definitions/Duration"
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"type": "object", "type": "object",
"required": [ "required": [
"labels", "labels",
@@ -3606,7 +3647,7 @@
"description": "receivers", "description": "receivers",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/Receiver" "$ref": "#/definitions/receiver"
}, },
"x-go-name": "Receivers" "x-go-name": "Receivers"
}, },
@@ -3627,16 +3668,17 @@
} }
}, },
"x-go-name": "GettableAlert", "x-go-name": "GettableAlert",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableAlert"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
}, },
"x-go-name": "GettableAlerts", "x-go-name": "GettableAlerts",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableAlerts"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence", "description": "GettableSilence gettable silence",
@@ -3692,17 +3734,16 @@
"x-go-name": "UpdatedAt" "x-go-name": "UpdatedAt"
} }
}, },
"x-go-name": "GettableSilence", "$ref": "#/definitions/gettableSilence"
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"gettableSilences": { "gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/GettableSilence" "$ref": "#/definitions/gettableSilence"
}, },
"x-go-name": "GettableSilences", "x-go-name": "GettableSilences",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableSilences"
}, },
"labelSet": { "labelSet": {
"description": "LabelSet label set", "description": "LabelSet label set",
@@ -3831,7 +3872,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}, },
"postableSilence": { "postableSilence": {
"description": "PostableSilence postable silence",
"type": "object", "type": "object",
"required": [ "required": [
"comment", "comment",
@@ -3873,10 +3913,10 @@
} }
}, },
"x-go-name": "PostableSilence", "x-go-name": "PostableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/postableSilence"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"type": "object", "type": "object",
"required": [ "required": [
"name" "name"
@@ -3889,7 +3929,8 @@
} }
}, },
"x-go-name": "Receiver", "x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models" "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/receiver"
}, },
"silence": { "silence": {
"description": "Silence silence", "description": "Silence silence",
+3 -4
View File
@@ -10,10 +10,12 @@ type AlertConfiguration struct {
ConfigurationVersion string ConfigurationVersion string
CreatedAt int64 `xorm:"created"` CreatedAt int64 `xorm:"created"`
Default bool Default bool
OrgID int64 `xorm:"org_id"`
} }
// GetLatestAlertmanagerConfigurationQuery is the query to get the latest alertmanager configuration. // GetLatestAlertmanagerConfigurationQuery is the query to get the latest alertmanager configuration.
type GetLatestAlertmanagerConfigurationQuery struct { type GetLatestAlertmanagerConfigurationQuery struct {
OrgID int64
Result *AlertConfiguration Result *AlertConfiguration
} }
@@ -22,8 +24,5 @@ type SaveAlertmanagerConfigurationCmd struct {
AlertmanagerConfiguration string AlertmanagerConfiguration string
ConfigurationVersion string ConfigurationVersion string
Default bool Default bool
} OrgID int64
type DeleteAlertmanagerConfigurationCmd struct {
ID int64
} }
+12 -10
View File
@@ -81,17 +81,19 @@ func (ng *AlertNG) Init() error {
} }
schedCfg := schedule.SchedulerCfg{ schedCfg := schedule.SchedulerCfg{
C: clock.New(), C: clock.New(),
BaseInterval: baseInterval, BaseInterval: baseInterval,
Logger: log.New("ngalert.scheduler"), Logger: log.New("ngalert.scheduler"),
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
Evaluator: eval.Evaluator{Cfg: ng.Cfg, Log: ng.Log}, Evaluator: eval.Evaluator{Cfg: ng.Cfg, Log: ng.Log},
InstanceStore: store, InstanceStore: store,
RuleStore: store, RuleStore: store,
AdminConfigStore: store, AdminConfigStore: store,
Notifier: ng.Alertmanager, Notifier: ng.Alertmanager,
Metrics: ng.Metrics, Metrics: ng.Metrics,
AdminConfigPollInterval: ng.Cfg.AdminConfigPollInterval,
} }
ng.stateManager = state.NewManager(ng.Log, ng.Metrics, store, store) ng.stateManager = state.NewManager(ng.Log, ng.Metrics, store, store)
ng.schedule = schedule.NewScheduler(schedCfg, ng.DataService, ng.Cfg.AppURL, ng.stateManager) ng.schedule = schedule.NewScheduler(schedCfg, ng.DataService, ng.Cfg.AppURL, ng.stateManager)
+13 -7
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv"
"sync" "sync"
"time" "time"
@@ -72,6 +73,8 @@ const (
} }
} }
` `
//TODO: temporary until fix org isolation
mainOrgID = 1
) )
type Alertmanager struct { type Alertmanager struct {
@@ -168,7 +171,7 @@ func (am *Alertmanager) Ready() bool {
func (am *Alertmanager) Run(ctx context.Context) error { func (am *Alertmanager) Run(ctx context.Context) error {
// Make sure dispatcher starts. We can tolerate future reload failures. // Make sure dispatcher starts. We can tolerate future reload failures.
if err := am.SyncAndApplyConfigFromDatabase(); err != nil { if err := am.SyncAndApplyConfigFromDatabase(mainOrgID); err != nil {
am.logger.Error("unable to sync configuration", "err", err) am.logger.Error("unable to sync configuration", "err", err)
} }
@@ -177,7 +180,7 @@ func (am *Alertmanager) Run(ctx context.Context) error {
case <-ctx.Done(): case <-ctx.Done():
return am.StopAndWait() return am.StopAndWait()
case <-time.After(pollInterval): case <-time.After(pollInterval):
if err := am.SyncAndApplyConfigFromDatabase(); err != nil { if err := am.SyncAndApplyConfigFromDatabase(mainOrgID); err != nil {
am.logger.Error("unable to sync configuration", "err", err) am.logger.Error("unable to sync configuration", "err", err)
} }
} }
@@ -203,7 +206,7 @@ func (am *Alertmanager) StopAndWait() error {
// SaveAndApplyDefaultConfig saves the default configuration the database and applies the configuration to the Alertmanager. // SaveAndApplyDefaultConfig saves the default configuration the database and applies the configuration to the Alertmanager.
// It rollbacks the save if we fail to apply the configuration. // It rollbacks the save if we fail to apply the configuration.
func (am *Alertmanager) SaveAndApplyDefaultConfig() error { func (am *Alertmanager) SaveAndApplyDefaultConfig(orgID int64) error {
am.reloadConfigMtx.Lock() am.reloadConfigMtx.Lock()
defer am.reloadConfigMtx.Unlock() defer am.reloadConfigMtx.Unlock()
@@ -211,6 +214,7 @@ func (am *Alertmanager) SaveAndApplyDefaultConfig() error {
AlertmanagerConfiguration: alertmanagerDefaultConfiguration, AlertmanagerConfiguration: alertmanagerDefaultConfiguration,
Default: true, Default: true,
ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion), ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion),
OrgID: orgID,
} }
cfg, err := Load([]byte(alertmanagerDefaultConfiguration)) cfg, err := Load([]byte(alertmanagerDefaultConfiguration))
@@ -234,7 +238,7 @@ func (am *Alertmanager) SaveAndApplyDefaultConfig() error {
// SaveAndApplyConfig saves the configuration the database and applies the configuration to the Alertmanager. // SaveAndApplyConfig saves the configuration the database and applies the configuration to the Alertmanager.
// It rollbacks the save if we fail to apply the configuration. // It rollbacks the save if we fail to apply the configuration.
func (am *Alertmanager) SaveAndApplyConfig(cfg *apimodels.PostableUserConfig) error { func (am *Alertmanager) SaveAndApplyConfig(orgID int64, cfg *apimodels.PostableUserConfig) error {
rawConfig, err := json.Marshal(&cfg) rawConfig, err := json.Marshal(&cfg)
if err != nil { if err != nil {
return fmt.Errorf("failed to serialize to the Alertmanager configuration: %w", err) return fmt.Errorf("failed to serialize to the Alertmanager configuration: %w", err)
@@ -246,6 +250,7 @@ func (am *Alertmanager) SaveAndApplyConfig(cfg *apimodels.PostableUserConfig) er
cmd := &ngmodels.SaveAlertmanagerConfigurationCmd{ cmd := &ngmodels.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(rawConfig), AlertmanagerConfiguration: string(rawConfig),
ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion), ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion),
OrgID: orgID,
} }
err = am.Store.SaveAlertmanagerConfigurationWithCallback(cmd, func() error { err = am.Store.SaveAlertmanagerConfigurationWithCallback(cmd, func() error {
@@ -264,12 +269,12 @@ func (am *Alertmanager) SaveAndApplyConfig(cfg *apimodels.PostableUserConfig) er
// SyncAndApplyConfigFromDatabase picks the latest config from database and restarts // SyncAndApplyConfigFromDatabase picks the latest config from database and restarts
// the components with the new config. // the components with the new config.
func (am *Alertmanager) SyncAndApplyConfigFromDatabase() error { func (am *Alertmanager) SyncAndApplyConfigFromDatabase(orgID int64) error {
am.reloadConfigMtx.Lock() am.reloadConfigMtx.Lock()
defer am.reloadConfigMtx.Unlock() defer am.reloadConfigMtx.Unlock()
// First, let's get the configuration we need from the database. // First, let's get the configuration we need from the database.
q := &ngmodels.GetLatestAlertmanagerConfigurationQuery{} q := &ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: mainOrgID}
if err := am.Store.GetLatestAlertmanagerConfiguration(q); err != nil { if err := am.Store.GetLatestAlertmanagerConfiguration(q); err != nil {
// If there's no configuration in the database, let's use the default configuration. // If there's no configuration in the database, let's use the default configuration.
if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
@@ -279,6 +284,7 @@ func (am *Alertmanager) SyncAndApplyConfigFromDatabase() error {
AlertmanagerConfiguration: alertmanagerDefaultConfiguration, AlertmanagerConfiguration: alertmanagerDefaultConfiguration,
Default: true, Default: true,
ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion), ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion),
OrgID: orgID,
} }
if err := am.Store.SaveAlertmanagerConfiguration(savecmd); err != nil { if err := am.Store.SaveAlertmanagerConfiguration(savecmd); err != nil {
return err return err
@@ -399,7 +405,7 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig
} }
func (am *Alertmanager) WorkingDirPath() string { func (am *Alertmanager) WorkingDirPath() string {
return filepath.Join(am.Settings.DataPath, workingDir) return filepath.Join(am.Settings.DataPath, workingDir, strconv.Itoa(mainOrgID))
} }
// buildIntegrationsMap builds a map of name to the list of Grafana integration notifiers off of a list of receiver config. // buildIntegrationsMap builds a map of name to the list of Grafana integration notifiers off of a list of receiver config.
@@ -54,7 +54,7 @@ func setupAMTest(t *testing.T) *Alertmanager {
func TestAlertmanager_ShouldUseDefaultConfigurationWhenNoConfiguration(t *testing.T) { func TestAlertmanager_ShouldUseDefaultConfigurationWhenNoConfiguration(t *testing.T) {
am := setupAMTest(t) am := setupAMTest(t)
require.NoError(t, am.SyncAndApplyConfigFromDatabase()) require.NoError(t, am.SyncAndApplyConfigFromDatabase(mainOrgID))
require.NotNil(t, am.config) require.NotNil(t, am.config)
} }
+40 -38
View File
@@ -25,14 +25,13 @@ import (
// timeNow makes it possible to test usage of time // timeNow makes it possible to test usage of time
var timeNow = time.Now var timeNow = time.Now
// AdminConfigPollingInterval of how often we sync admin configuration.
var AdminConfigPollingInterval = 1 * time.Minute
// ScheduleService handles scheduling // ScheduleService handles scheduling
type ScheduleService interface { type ScheduleService interface {
Run(context.Context) error Run(context.Context) error
Pause() error Pause() error
Unpause() error Unpause() error
AlertmanagersFor(orgID int64) []*url.URL
DroppedAlertmanagersFor(orgID int64) []*url.URL
// the following are used by tests only used for tests // the following are used by tests only used for tests
evalApplied(models.AlertRuleKey, time.Time) evalApplied(models.AlertRuleKey, time.Time)
@@ -85,50 +84,53 @@ type schedule struct {
metrics *metrics.Metrics metrics *metrics.Metrics
// Senders help us send alerts to external Alertmanagers. // Senders help us send alerts to external Alertmanagers.
sendersMtx sync.RWMutex sendersMtx sync.RWMutex
sendersCfgHash map[int64]string sendersCfgHash map[int64]string
senders map[int64]*sender.Sender senders map[int64]*sender.Sender
adminConfigPollInterval time.Duration
} }
// SchedulerCfg is the scheduler configuration. // SchedulerCfg is the scheduler configuration.
type SchedulerCfg struct { type SchedulerCfg struct {
C clock.Clock C clock.Clock
BaseInterval time.Duration BaseInterval time.Duration
Logger log.Logger Logger log.Logger
EvalAppliedFunc func(models.AlertRuleKey, time.Time) EvalAppliedFunc func(models.AlertRuleKey, time.Time)
MaxAttempts int64 MaxAttempts int64
StopAppliedFunc func(models.AlertRuleKey) StopAppliedFunc func(models.AlertRuleKey)
Evaluator eval.Evaluator Evaluator eval.Evaluator
RuleStore store.RuleStore RuleStore store.RuleStore
InstanceStore store.InstanceStore InstanceStore store.InstanceStore
AdminConfigStore store.AdminConfigurationStore AdminConfigStore store.AdminConfigurationStore
Notifier Notifier Notifier Notifier
Metrics *metrics.Metrics Metrics *metrics.Metrics
AdminConfigPollInterval time.Duration
} }
// NewScheduler returns a new schedule. // NewScheduler returns a new schedule.
func NewScheduler(cfg SchedulerCfg, dataService *tsdb.Service, appURL string, stateManager *state.Manager) *schedule { func NewScheduler(cfg SchedulerCfg, dataService *tsdb.Service, appURL string, stateManager *state.Manager) *schedule {
ticker := alerting.NewTicker(cfg.C.Now(), time.Second*0, cfg.C, int64(cfg.BaseInterval.Seconds())) ticker := alerting.NewTicker(cfg.C.Now(), time.Second*0, cfg.C, int64(cfg.BaseInterval.Seconds()))
sch := schedule{ sch := schedule{
registry: alertRuleRegistry{alertRuleInfo: make(map[models.AlertRuleKey]alertRuleInfo)}, registry: alertRuleRegistry{alertRuleInfo: make(map[models.AlertRuleKey]alertRuleInfo)},
maxAttempts: cfg.MaxAttempts, maxAttempts: cfg.MaxAttempts,
clock: cfg.C, clock: cfg.C,
baseInterval: cfg.BaseInterval, baseInterval: cfg.BaseInterval,
log: cfg.Logger, log: cfg.Logger,
heartbeat: ticker, heartbeat: ticker,
evalAppliedFunc: cfg.EvalAppliedFunc, evalAppliedFunc: cfg.EvalAppliedFunc,
stopAppliedFunc: cfg.StopAppliedFunc, stopAppliedFunc: cfg.StopAppliedFunc,
evaluator: cfg.Evaluator, evaluator: cfg.Evaluator,
ruleStore: cfg.RuleStore, ruleStore: cfg.RuleStore,
instanceStore: cfg.InstanceStore, instanceStore: cfg.InstanceStore,
dataService: dataService, dataService: dataService,
adminConfigStore: cfg.AdminConfigStore, adminConfigStore: cfg.AdminConfigStore,
notifier: cfg.Notifier, notifier: cfg.Notifier,
metrics: cfg.Metrics, metrics: cfg.Metrics,
appURL: appURL, appURL: appURL,
stateManager: stateManager, stateManager: stateManager,
senders: map[int64]*sender.Sender{}, senders: map[int64]*sender.Sender{},
sendersCfgHash: map[int64]string{}, sendersCfgHash: map[int64]string{},
adminConfigPollInterval: cfg.AdminConfigPollInterval,
} }
return &sch return &sch
} }
@@ -290,7 +292,7 @@ func (sch *schedule) DroppedAlertmanagersFor(orgID int64) []*url.URL {
func (sch *schedule) adminConfigSync(ctx context.Context) error { func (sch *schedule) adminConfigSync(ctx context.Context) error {
for { for {
select { select {
case <-time.After(AdminConfigPollingInterval): case <-time.After(sch.adminConfigPollInterval):
if err := sch.SyncAndApplyConfigFromDatabase(); err != nil { if err := sch.SyncAndApplyConfigFromDatabase(); err != nil {
sch.log.Error("unable to sync admin configuration", "err", err) sch.log.Error("unable to sync admin configuration", "err", err)
} }
@@ -99,9 +99,10 @@ func TestWarmStateCache(t *testing.T) {
BaseInterval: time.Second, BaseInterval: time.Second,
Logger: log.New("ngalert cache warming test"), Logger: log.New("ngalert cache warming test"),
RuleStore: dbstore, RuleStore: dbstore,
InstanceStore: dbstore, InstanceStore: dbstore,
Metrics: metrics.NewMetrics(prometheus.NewRegistry()), Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
} }
st := state.NewManager(schedCfg.Logger, nilMetrics, dbstore, dbstore) st := state.NewManager(schedCfg.Logger, nilMetrics, dbstore, dbstore)
st.Warm() st.Warm()
@@ -143,10 +144,11 @@ func TestAlertingTicker(t *testing.T) {
StopAppliedFunc: func(alertDefKey models.AlertRuleKey) { StopAppliedFunc: func(alertDefKey models.AlertRuleKey) {
stopAppliedCh <- alertDefKey stopAppliedCh <- alertDefKey
}, },
RuleStore: dbstore, RuleStore: dbstore,
InstanceStore: dbstore, InstanceStore: dbstore,
Logger: log.New("ngalert schedule test"), Logger: log.New("ngalert schedule test"),
Metrics: metrics.NewMetrics(prometheus.NewRegistry()), Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
} }
st := state.NewManager(schedCfg.Logger, nilMetrics, dbstore, dbstore) st := state.NewManager(schedCfg.Logger, nilMetrics, dbstore, dbstore)
sched := schedule.NewScheduler(schedCfg, nil, "http://localhost", st) sched := schedule.NewScheduler(schedCfg, nil, "http://localhost", st)
@@ -27,7 +27,7 @@ import (
func TestSendingToExternalAlertmanager(t *testing.T) { func TestSendingToExternalAlertmanager(t *testing.T) {
t.Cleanup(registry.ClearOverrides) t.Cleanup(registry.ClearOverrides)
fakeAM := newFakeExternalAlertmanager(t) fakeAM := NewFakeExternalAlertmanager(t)
defer fakeAM.Close() defer fakeAM.Close()
fakeRuleStore := newFakeRuleStore(t) fakeRuleStore := newFakeRuleStore(t)
fakeInstanceStore := &fakeInstanceStore{} fakeInstanceStore := &fakeInstanceStore{}
@@ -61,7 +61,6 @@ func TestSendingToExternalAlertmanager(t *testing.T) {
cancel() cancel()
}) })
go func() { go func() {
AdminConfigPollingInterval = 10 * time.Minute // Do not poll in unit tests.
err := sched.Run(ctx) err := sched.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
}() }()
@@ -95,7 +94,7 @@ func TestSendingToExternalAlertmanager(t *testing.T) {
func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) { func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
t.Cleanup(registry.ClearOverrides) t.Cleanup(registry.ClearOverrides)
fakeAM := newFakeExternalAlertmanager(t) fakeAM := NewFakeExternalAlertmanager(t)
defer fakeAM.Close() defer fakeAM.Close()
fakeRuleStore := newFakeRuleStore(t) fakeRuleStore := newFakeRuleStore(t)
fakeInstanceStore := &fakeInstanceStore{} fakeInstanceStore := &fakeInstanceStore{}
@@ -130,7 +129,6 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
cancel() cancel()
}) })
go func() { go func() {
AdminConfigPollingInterval = 10 * time.Minute // Do not poll in unit tests.
err := sched.Run(ctx) err := sched.Run(ctx)
require.NoError(t, err) require.NoError(t, err)
}() }()
@@ -161,7 +159,7 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
}, 20*time.Second, 200*time.Millisecond) }, 20*time.Second, 200*time.Millisecond)
// 2. Next, let's modify the configuration of an organization by adding an extra alertmanager. // 2. Next, let's modify the configuration of an organization by adding an extra alertmanager.
fakeAM2 := newFakeExternalAlertmanager(t) fakeAM2 := NewFakeExternalAlertmanager(t)
adminConfig2 = &models.AdminConfiguration{OrgID: 2, Alertmanagers: []string{fakeAM.server.URL, fakeAM2.server.URL}} adminConfig2 = &models.AdminConfiguration{OrgID: 2, Alertmanagers: []string{fakeAM.server.URL, fakeAM2.server.URL}}
cmd = store.UpdateAdminConfigurationCmd{AdminConfiguration: adminConfig2} cmd = store.UpdateAdminConfigurationCmd{AdminConfiguration: adminConfig2}
require.NoError(t, fakeAdminConfigStore.UpdateAdminConfiguration(cmd)) require.NoError(t, fakeAdminConfigStore.UpdateAdminConfiguration(cmd))
@@ -238,16 +236,17 @@ func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, ac
logger := log.New("ngalert schedule test") logger := log.New("ngalert schedule test")
nilMetrics := metrics.NewMetrics(nil) nilMetrics := metrics.NewMetrics(nil)
schedCfg := SchedulerCfg{ schedCfg := SchedulerCfg{
C: mockedClock, C: mockedClock,
BaseInterval: time.Second, BaseInterval: time.Second,
MaxAttempts: 1, MaxAttempts: 1,
Evaluator: eval.Evaluator{Cfg: &setting.Cfg{ExpressionsEnabled: true}, Log: logger}, Evaluator: eval.Evaluator{Cfg: &setting.Cfg{ExpressionsEnabled: true}, Log: logger},
RuleStore: rs, RuleStore: rs,
InstanceStore: is, InstanceStore: is,
AdminConfigStore: acs, AdminConfigStore: acs,
Notifier: &fakeNotifier{}, Notifier: &fakeNotifier{},
Logger: logger, Logger: logger,
Metrics: metrics.NewMetrics(prometheus.NewRegistry()), Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
} }
st := state.NewManager(schedCfg.Logger, nilMetrics, rs, is) st := state.NewManager(schedCfg.Logger, nilMetrics, rs, is)
return NewScheduler(schedCfg, nil, "http://localhost", st), mockedClock return NewScheduler(schedCfg, nil, "http://localhost", st), mockedClock
+12 -8
View File
@@ -237,17 +237,17 @@ func (n *fakeNotifier) PutAlerts(alerts apimodels.PostableAlerts) error {
return nil return nil
} }
type fakeExternalAlertmanager struct { type FakeExternalAlertmanager struct {
t *testing.T t *testing.T
mtx sync.Mutex mtx sync.Mutex
alerts amv2.PostableAlerts alerts amv2.PostableAlerts
server *httptest.Server server *httptest.Server
} }
func newFakeExternalAlertmanager(t *testing.T) *fakeExternalAlertmanager { func NewFakeExternalAlertmanager(t *testing.T) *FakeExternalAlertmanager {
t.Helper() t.Helper()
am := &fakeExternalAlertmanager{ am := &FakeExternalAlertmanager{
t: t, t: t,
alerts: amv2.PostableAlerts{}, alerts: amv2.PostableAlerts{},
} }
@@ -256,7 +256,11 @@ func newFakeExternalAlertmanager(t *testing.T) *fakeExternalAlertmanager {
return am return am
} }
func (am *fakeExternalAlertmanager) AlertNamesCompare(expected []string) bool { func (am *FakeExternalAlertmanager) URL() string {
return am.server.URL
}
func (am *FakeExternalAlertmanager) AlertNamesCompare(expected []string) bool {
n := []string{} n := []string{}
alerts := am.Alerts() alerts := am.Alerts()
@@ -275,20 +279,20 @@ func (am *fakeExternalAlertmanager) AlertNamesCompare(expected []string) bool {
return assert.ObjectsAreEqual(expected, n) return assert.ObjectsAreEqual(expected, n)
} }
func (am *fakeExternalAlertmanager) AlertsCount() int { func (am *FakeExternalAlertmanager) AlertsCount() int {
am.mtx.Lock() am.mtx.Lock()
defer am.mtx.Unlock() defer am.mtx.Unlock()
return len(am.alerts) return len(am.alerts)
} }
func (am *fakeExternalAlertmanager) Alerts() amv2.PostableAlerts { func (am *FakeExternalAlertmanager) Alerts() amv2.PostableAlerts {
am.mtx.Lock() am.mtx.Lock()
defer am.mtx.Unlock() defer am.mtx.Unlock()
return am.alerts return am.alerts
} }
func (am *fakeExternalAlertmanager) Handler() func(w http.ResponseWriter, r *http.Request) { func (am *FakeExternalAlertmanager) Handler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
b, err := ioutil.ReadAll(r.Body) b, err := ioutil.ReadAll(r.Body)
require.NoError(am.t, err) require.NoError(am.t, err)
@@ -302,6 +306,6 @@ func (am *fakeExternalAlertmanager) Handler() func(w http.ResponseWriter, r *htt
} }
} }
func (am *fakeExternalAlertmanager) Close() { func (am *FakeExternalAlertmanager) Close() {
am.server.Close() am.server.Close()
} }
+5 -3
View File
@@ -181,7 +181,7 @@ func (st *Manager) setNextState(alertRule *ngModels.AlertRule, result eval.Resul
st.set(currentState) st.set(currentState)
if oldState != currentState.State { if oldState != currentState.State {
go st.createAlertAnnotation(currentState.State, alertRule, result) go st.createAlertAnnotation(currentState.State, alertRule, result, oldState)
} }
return currentState return currentState
} }
@@ -229,7 +229,7 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State {
} }
} }
func (st *Manager) createAlertAnnotation(new eval.State, alertRule *ngModels.AlertRule, result eval.Result) { func (st *Manager) createAlertAnnotation(new eval.State, alertRule *ngModels.AlertRule, result eval.Result, oldState eval.State) {
st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", new.String()) st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", new.String())
dashUid, ok := alertRule.Annotations["__dashboardUid__"] dashUid, ok := alertRule.Annotations["__dashboardUid__"]
if !ok { if !ok {
@@ -255,12 +255,14 @@ func (st *Manager) createAlertAnnotation(new eval.State, alertRule *ngModels.Ale
return return
} }
annotationText := fmt.Sprintf("%s %s", result.Instance.String(), new.String()) annotationText := fmt.Sprintf("%s {%s} - %s", alertRule.Title, result.Instance.String(), new.String())
item := &annotations.Item{ item := &annotations.Item{
OrgId: alertRule.OrgID, OrgId: alertRule.OrgID,
DashboardId: query.Result.Id, DashboardId: query.Result.Id,
PanelId: panelId, PanelId: panelId,
PrevState: oldState.String(),
NewState: new.String(),
Text: annotationText, Text: annotationText,
Epoch: result.EvaluatedAt.UnixNano() / int64(time.Millisecond), Epoch: result.EvaluatedAt.UnixNano() / int64(time.Millisecond),
} }
+2 -1
View File
@@ -19,7 +19,7 @@ func (st *DBstore) GetLatestAlertmanagerConfiguration(query *models.GetLatestAle
return st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { return st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
c := &models.AlertConfiguration{} c := &models.AlertConfiguration{}
// The ID is already an auto incremental column, using the ID as an order should guarantee the latest. // The ID is already an auto incremental column, using the ID as an order should guarantee the latest.
ok, err := sess.Desc("id").Limit(1).Get(c) ok, err := sess.Desc("id").Where("org_id = ?", query.OrgID).Limit(1).Get(c)
if err != nil { if err != nil {
return err return err
} }
@@ -48,6 +48,7 @@ func (st DBstore) SaveAlertmanagerConfigurationWithCallback(cmd *models.SaveAler
AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, AlertmanagerConfiguration: cmd.AlertmanagerConfiguration,
ConfigurationVersion: cmd.ConfigurationVersion, ConfigurationVersion: cmd.ConfigurationVersion,
Default: cmd.Default, Default: cmd.Default,
OrgID: cmd.OrgID,
} }
if _, err := sess.Insert(config); err != nil { if _, err := sess.Insert(config); err != nil {
return err return err
@@ -41,6 +41,7 @@ func AddMigrations(mg *Migrator) {
ualert.AddTablesMigrations(mg) ualert.AddTablesMigrations(mg)
ualert.AddDashAlertMigration(mg) ualert.AddDashAlertMigration(mg)
addLibraryElementsMigrations(mg) addLibraryElementsMigrations(mg)
ualert.RerunDashAlertMigration(mg)
} }
func addMigrationLogMigrations(mg *Migrator) { func addMigrationLogMigrations(mg *Migrator) {
@@ -9,14 +9,14 @@ import (
) )
type alertRule struct { type alertRule struct {
OrgId int64 OrgID int64 `xorm:"org_id"`
Title string Title string
Condition string Condition string
Data []alertQuery Data []alertQuery
IntervalSeconds int64 IntervalSeconds int64
Version int64 Version int64
Uid string UID string `xorm:"uid"`
NamespaceUid string NamespaceUID string `xorm:"namespace_uid"`
RuleGroup string RuleGroup string
NoDataState string NoDataState string
ExecErrState string ExecErrState string
@@ -51,9 +51,9 @@ type alertRuleVersion struct {
func (a *alertRule) makeVersion() *alertRuleVersion { func (a *alertRule) makeVersion() *alertRuleVersion {
return &alertRuleVersion{ return &alertRuleVersion{
RuleOrgID: a.OrgId, RuleOrgID: a.OrgID,
RuleUID: a.Uid, RuleUID: a.UID,
RuleNamespaceUID: a.NamespaceUid, RuleNamespaceUID: a.NamespaceUID,
RuleGroup: a.RuleGroup, RuleGroup: a.RuleGroup,
ParentVersion: 0, ParentVersion: 0,
RestoredFrom: 0, RestoredFrom: 0,
@@ -96,14 +96,14 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string
annotations["message"] = da.Message annotations["message"] = da.Message
ar := &alertRule{ ar := &alertRule{
OrgId: da.OrgId, OrgID: da.OrgId,
Title: da.Name, // TODO: Make sure all names are unique, make new name on constraint insert error. Title: da.Name, // TODO: Make sure all names are unique, make new name on constraint insert error.
Uid: util.GenerateShortUID(), UID: util.GenerateShortUID(),
Condition: cond.Condition, Condition: cond.Condition,
Data: cond.Data, Data: cond.Data,
IntervalSeconds: ruleAdjustInterval(da.Frequency), IntervalSeconds: ruleAdjustInterval(da.Frequency),
Version: 1, Version: 1,
NamespaceUid: folderUID, // Folder already created, comes from env var. NamespaceUID: folderUID, // Folder already created, comes from env var.
RuleGroup: da.Name, RuleGroup: da.Name,
For: duration(da.For), For: duration(da.For),
Updated: time.Now().UTC(), Updated: time.Now().UTC(),
@@ -123,7 +123,7 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string
} }
// Label for routing and silences. // Label for routing and silences.
n, v := getLabelForRouteMatching(ar.Uid) n, v := getLabelForRouteMatching(ar.UID)
ar.Labels[n] = v ar.Labels[n] = v
if err := m.addSilence(da, ar); err != nil { if err := m.addSilence(da, ar); err != nil {
@@ -17,7 +17,8 @@ import (
) )
type notificationChannel struct { type notificationChannel struct {
ID int `xorm:"id"` ID int64 `xorm:"id"`
OrgID int64 `xorm:"org_id"`
Uid string `xorm:"uid"` Uid string `xorm:"uid"`
Name string `xorm:"name"` Name string `xorm:"name"`
Type string `xorm:"type"` Type string `xorm:"type"`
@@ -27,9 +28,16 @@ type notificationChannel struct {
SecureSettings securejsondata.SecureJsonData `xorm:"secure_settings"` SecureSettings securejsondata.SecureJsonData `xorm:"secure_settings"`
} }
func (m *migration) getNotificationChannelMap() (map[interface{}]*notificationChannel, []*notificationChannel, error) { // channelsPerOrg maps notification channels per organisation
type channelsPerOrg map[int64]map[interface{}]*notificationChannel
// channelMap maps notification channels per organisation
type defaultChannelsPerOrg map[int64][]*notificationChannel
func (m *migration) getNotificationChannelMap() (channelsPerOrg, defaultChannelsPerOrg, error) {
q := ` q := `
SELECT id, SELECT id,
org_id,
uid, uid,
name, name,
type, type,
@@ -50,25 +58,27 @@ func (m *migration) getNotificationChannelMap() (map[interface{}]*notificationCh
return nil, nil, nil return nil, nil, nil
} }
allChannelsMap := make(map[interface{}]*notificationChannel) allChannelsMap := make(channelsPerOrg)
var defaultChannels []*notificationChannel defaultChannelsMap := make(defaultChannelsPerOrg)
for i, c := range allChannels { for i, c := range allChannels {
if _, ok := allChannelsMap[c.OrgID]; !ok { // new seen org
allChannelsMap[c.OrgID] = make(map[interface{}]*notificationChannel)
}
if c.Uid != "" { if c.Uid != "" {
allChannelsMap[c.Uid] = &allChannels[i] allChannelsMap[c.OrgID][c.Uid] = &allChannels[i]
} }
if c.ID != 0 { if c.ID != 0 {
allChannelsMap[c.ID] = &allChannels[i] allChannelsMap[c.OrgID][c.ID] = &allChannels[i]
} }
if c.IsDefault { if c.IsDefault {
// TODO: verify that there will be only 1 default channel. defaultChannelsMap[c.OrgID] = append(defaultChannelsMap[c.OrgID], &allChannels[i])
defaultChannels = append(defaultChannels, &allChannels[i])
} }
} }
return allChannelsMap, defaultChannels, nil return allChannelsMap, defaultChannelsMap, nil
} }
func (m *migration) updateReceiverAndRoute(allChannels map[interface{}]*notificationChannel, defaultChannels []*notificationChannel, da dashAlert, rule *alertRule, amConfig *PostableUserConfig) error { func (m *migration) updateReceiverAndRoute(allChannels channelsPerOrg, defaultChannels defaultChannelsPerOrg, da dashAlert, rule *alertRule, amConfig *PostableUserConfig) error {
// Create receiver and route for this rule. // Create receiver and route for this rule.
if allChannels == nil { if allChannels == nil {
return nil return nil
@@ -82,7 +92,7 @@ func (m *migration) updateReceiverAndRoute(allChannels map[interface{}]*notifica
return nil return nil
} }
recv, route, err := m.makeReceiverAndRoute(rule.Uid, channelIDs, defaultChannels, allChannels) recv, route, err := m.makeReceiverAndRoute(rule.UID, rule.OrgID, channelIDs, defaultChannels[rule.OrgID], allChannels[rule.OrgID])
if err != nil { if err != nil {
return err return err
} }
@@ -97,7 +107,7 @@ func (m *migration) updateReceiverAndRoute(allChannels map[interface{}]*notifica
return nil return nil
} }
func (m *migration) makeReceiverAndRoute(ruleUid string, channelUids []interface{}, defaultChannels []*notificationChannel, allChannels map[interface{}]*notificationChannel) (*PostableApiReceiver, *Route, error) { func (m *migration) makeReceiverAndRoute(ruleUid string, orgID int64, channelUids []interface{}, defaultChannels []*notificationChannel, allChannels map[interface{}]*notificationChannel) (*PostableApiReceiver, *Route, error) {
portedChannels := []*PostableGrafanaReceiver{} portedChannels := []*PostableGrafanaReceiver{}
var receiver *PostableApiReceiver var receiver *PostableApiReceiver
@@ -112,7 +122,10 @@ func (m *migration) makeReceiverAndRoute(ruleUid string, channelUids []interface
return errors.New("failed to generate UID for notification channel") return errors.New("failed to generate UID for notification channel")
} }
m.migratedChannels[c] = struct{}{} if _, ok := m.migratedChannelsPerOrg[orgID]; !ok {
m.migratedChannelsPerOrg[orgID] = make(map[*notificationChannel]struct{})
}
m.migratedChannelsPerOrg[orgID][c] = struct{}{}
settings, secureSettings := migrateSettingsToSecureSettings(c.Type, c.Settings, c.SecureSettings) settings, secureSettings := migrateSettingsToSecureSettings(c.Type, c.Settings, c.SecureSettings)
portedChannels = append(portedChannels, &PostableGrafanaReceiver{ portedChannels = append(portedChannels, &PostableGrafanaReceiver{
UID: uid, UID: uid,
@@ -129,9 +142,10 @@ func (m *migration) makeReceiverAndRoute(ruleUid string, channelUids []interface
// Remove obsolete notification channels. // Remove obsolete notification channels.
filteredChannelUids := make(map[interface{}]struct{}) filteredChannelUids := make(map[interface{}]struct{})
for _, uid := range channelUids { for _, uid := range channelUids {
_, ok := allChannels[uid] c, ok := allChannels[uid]
if ok { if ok {
filteredChannelUids[uid] = struct{}{} // always store the channel UID to prevent duplicates
filteredChannelUids[c.Uid] = struct{}{}
} else { } else {
m.mg.Logger.Warn("ignoring obsolete notification channel", "uid", uid) m.mg.Logger.Warn("ignoring obsolete notification channel", "uid", uid)
} }
@@ -142,9 +156,10 @@ func (m *migration) makeReceiverAndRoute(ruleUid string, channelUids []interface
if c.Uid == "" { if c.Uid == "" {
id = c.ID id = c.ID
} }
_, ok := allChannels[id] c, ok := allChannels[id]
if ok { if ok {
filteredChannelUids[id] = struct{}{} // always store the channel UID to prevent duplicates
filteredChannelUids[c.Uid] = struct{}{}
} }
} }
@@ -159,7 +174,11 @@ func (m *migration) makeReceiverAndRoute(ruleUid string, channelUids []interface
} }
var receiverName string var receiverName string
if rn, ok := m.portedChannelGroups[chanKey]; ok {
if _, ok := m.portedChannelGroupsPerOrg[orgID]; !ok {
m.portedChannelGroupsPerOrg[orgID] = make(map[string]string)
}
if rn, ok := m.portedChannelGroupsPerOrg[orgID][chanKey]; ok {
// We have ported these exact set of channels already. Re-use it. // We have ported these exact set of channels already. Re-use it.
receiverName = rn receiverName = rn
if receiverName == "autogen-contact-point-default" { if receiverName == "autogen-contact-point-default" {
@@ -180,7 +199,7 @@ func (m *migration) makeReceiverAndRoute(ruleUid string, channelUids []interface
receiverName = fmt.Sprintf("autogen-contact-point-%d", m.lastReceiverID) receiverName = fmt.Sprintf("autogen-contact-point-%d", m.lastReceiverID)
} }
m.portedChannelGroups[chanKey] = receiverName m.portedChannelGroupsPerOrg[orgID][chanKey] = receiverName
receiver = &PostableApiReceiver{ receiver = &PostableApiReceiver{
Name: receiverName, Name: receiverName,
GrafanaManagedReceivers: portedChannels, GrafanaManagedReceivers: portedChannels,
@@ -220,32 +239,47 @@ func makeKeyForChannelGroup(channelUids map[interface{}]struct{}) (string, error
} }
// addDefaultChannels should be called before adding any other routes. // addDefaultChannels should be called before adding any other routes.
func (m *migration) addDefaultChannels(amConfig *PostableUserConfig, allChannels map[interface{}]*notificationChannel, defaultChannels []*notificationChannel) error { func (m *migration) addDefaultChannels(amConfigsPerOrg amConfigsPerOrg, allChannels channelsPerOrg, defaultChannels defaultChannelsPerOrg) error {
// Default route and receiver. for orgID := range allChannels {
recv, route, err := m.makeReceiverAndRoute("default_route", nil, defaultChannels, allChannels) if _, ok := amConfigsPerOrg[orgID]; !ok {
if err != nil { amConfigsPerOrg[orgID] = &PostableUserConfig{
return err AlertmanagerConfig: PostableApiAlertingConfig{
} Receivers: make([]*PostableApiReceiver, 0),
Route: &Route{
Routes: make([]*Route, 0),
},
},
}
}
// Default route and receiver.
recv, route, err := m.makeReceiverAndRoute("default_route", orgID, nil, defaultChannels[orgID], allChannels[orgID])
if err != nil {
// if one fails it will fail the migration
return err
}
if recv != nil { if recv != nil {
amConfig.AlertmanagerConfig.Receivers = append(amConfig.AlertmanagerConfig.Receivers, recv) amConfigsPerOrg[orgID].AlertmanagerConfig.Receivers = append(amConfigsPerOrg[orgID].AlertmanagerConfig.Receivers, recv)
}
if route != nil {
route.Matchers = nil // Don't need matchers for root route.
amConfigsPerOrg[orgID].AlertmanagerConfig.Route = route
}
} }
if route != nil {
route.Matchers = nil // Don't need matchers for root route.
amConfig.AlertmanagerConfig.Route = route
}
return nil return nil
} }
func (m *migration) addUnmigratedChannels(amConfig *PostableUserConfig, allChannels map[interface{}]*notificationChannel, defaultChannels []*notificationChannel) error { func (m *migration) addUnmigratedChannels(orgID int64, amConfigs *PostableUserConfig, allChannels map[interface{}]*notificationChannel, defaultChannels []*notificationChannel) error {
// Unmigrated channels. // Unmigrated channels.
portedChannels := []*PostableGrafanaReceiver{} portedChannels := []*PostableGrafanaReceiver{}
receiver := &PostableApiReceiver{ receiver := &PostableApiReceiver{
Name: "autogen-unlinked-channel-recv", Name: "autogen-unlinked-channel-recv",
} }
for _, c := range allChannels { for _, c := range allChannels {
_, ok := m.migratedChannels[c] if _, ok := m.migratedChannelsPerOrg[orgID]; !ok {
m.migratedChannelsPerOrg[orgID] = make(map[*notificationChannel]struct{})
}
_, ok := m.migratedChannelsPerOrg[orgID][c]
if ok { if ok {
continue continue
} }
@@ -259,7 +293,7 @@ func (m *migration) addUnmigratedChannels(amConfig *PostableUserConfig, allChann
return errors.New("failed to generate UID for notification channel") return errors.New("failed to generate UID for notification channel")
} }
m.migratedChannels[c] = struct{}{} m.migratedChannelsPerOrg[orgID][c] = struct{}{}
settings, secureSettings := migrateSettingsToSecureSettings(c.Type, c.Settings, c.SecureSettings) settings, secureSettings := migrateSettingsToSecureSettings(c.Type, c.Settings, c.SecureSettings)
portedChannels = append(portedChannels, &PostableGrafanaReceiver{ portedChannels = append(portedChannels, &PostableGrafanaReceiver{
UID: uid, UID: uid,
@@ -272,7 +306,7 @@ func (m *migration) addUnmigratedChannels(amConfig *PostableUserConfig, allChann
} }
receiver.GrafanaManagedReceivers = portedChannels receiver.GrafanaManagedReceivers = portedChannels
if len(portedChannels) > 0 { if len(portedChannels) > 0 {
amConfig.AlertmanagerConfig.Receivers = append(amConfig.AlertmanagerConfig.Receivers, receiver) amConfigs.AlertmanagerConfig.Receivers = append(amConfigs.AlertmanagerConfig.Receivers, receiver)
} }
return nil return nil
@@ -361,6 +395,8 @@ type PostableUserConfig struct {
AlertmanagerConfig PostableApiAlertingConfig `yaml:"alertmanager_config" json:"alertmanager_config"` AlertmanagerConfig PostableApiAlertingConfig `yaml:"alertmanager_config" json:"alertmanager_config"`
} }
type amConfigsPerOrg = map[int64]*PostableUserConfig
func (c *PostableUserConfig) EncryptSecureSettings() error { func (c *PostableUserConfig) EncryptSecureSettings() error {
for _, r := range c.AlertmanagerConfig.Receivers { for _, r := range c.AlertmanagerConfig.Receivers {
for _, gr := range r.GrafanaManagedReceivers { for _, gr := range r.GrafanaManagedReceivers {
@@ -61,6 +61,25 @@ func (m *migration) getOrCreateGeneralFolder(orgID int64) (*dashboard, error) {
return &dashboard, nil return &dashboard, nil
} }
// returns the folder of the given dashboard (if exists)
func (m *migration) getFolder(dash dashboard, da dashAlert) (dashboard, error) {
// get folder if exists
folder := dashboard{}
if dash.FolderId > 0 {
exists, err := m.sess.Where("id=?", dash.FolderId).Get(&folder)
if err != nil {
return folder, fmt.Errorf("failed to get folder %d: %w", dash.FolderId, err)
}
if !exists {
return folder, fmt.Errorf("folder with id %v not found", dash.FolderId)
}
if !folder.IsFolder {
return folder, fmt.Errorf("id %v is a dashboard not a folder", dash.FolderId)
}
}
return folder, nil
}
// based on sqlstore.saveDashboard() // based on sqlstore.saveDashboard()
// it should be called from inside a transaction // it should be called from inside a transaction
func (m *migration) createFolder(orgID int64, title string) (*dashboard, error) { func (m *migration) createFolder(orgID int64, title string) (*dashboard, error) {
@@ -8,6 +8,7 @@ import (
"math/rand" "math/rand"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"time" "time"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
@@ -27,7 +28,7 @@ func (m *migration) addSilence(da dashAlert, rule *alertRule) error {
return errors.New("failed to create uuid for silence") return errors.New("failed to create uuid for silence")
} }
n, v := getLabelForRouteMatching(rule.Uid) n, v := getLabelForRouteMatching(rule.UID)
s := &pb.MeshSilence{ s := &pb.MeshSilence{
Silence: &pb.Silence{ Silence: &pb.Silence{
Id: uid.String(), Id: uid.String(),
@@ -50,7 +51,7 @@ func (m *migration) addSilence(da dashAlert, rule *alertRule) error {
return nil return nil
} }
func (m *migration) writeSilencesFile() error { func (m *migration) writeSilencesFile(orgID int64) error {
var buf bytes.Buffer var buf bytes.Buffer
for _, e := range m.silences { for _, e := range m.silences {
if _, err := pbutil.WriteDelimited(&buf, e); err != nil { if _, err := pbutil.WriteDelimited(&buf, e); err != nil {
@@ -58,7 +59,7 @@ func (m *migration) writeSilencesFile() error {
} }
} }
f, err := openReplace(silencesFileName(m.mg)) f, err := openReplace(silencesFileNameForOrg(m.mg, orgID))
if err != nil { if err != nil {
return err return err
} }
@@ -70,8 +71,12 @@ func (m *migration) writeSilencesFile() error {
return f.Close() return f.Close()
} }
func silencesFileName(mg *migrator.Migrator) string { func getSilenceFileNamesForAllOrgs(mg *migrator.Migrator) ([]string, error) {
return filepath.Join(mg.Cfg.DataPath, "alerting", "silences") return filepath.Glob(filepath.Join(mg.Cfg.DataPath, "alerting", "*", "silences"))
}
func silencesFileNameForOrg(mg *migrator.Migrator, orgID int64) string {
return filepath.Join(mg.Cfg.DataPath, "alerting", strconv.Itoa(int(orgID)), "silences")
} }
// replaceFile wraps a file that is moved to another filename on closing. // replaceFile wraps a file that is moved to another filename on closing.
@@ -94,6 +99,10 @@ func (f *replaceFile) Close() error {
func openReplace(filename string) (*replaceFile, error) { func openReplace(filename string) (*replaceFile, error) {
tmpFilename := fmt.Sprintf("%s.%x", filename, uint64(rand.Int63())) tmpFilename := fmt.Sprintf("%s.%x", filename, uint64(rand.Int63()))
if err := os.MkdirAll(filepath.Dir(tmpFilename), os.ModePerm); err != nil {
return nil, err
}
f, err := os.Create(tmpFilename) f, err := os.Create(tmpFilename)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -267,6 +267,15 @@ func AddAlertmanagerConfigMigrations(mg *migrator.Migrator) {
mg.AddMigration("alert alert_configuration alertmanager_configuration column from TEXT to MEDIUMTEXT if mysql", migrator.NewRawSQLMigration(""). mg.AddMigration("alert alert_configuration alertmanager_configuration column from TEXT to MEDIUMTEXT if mysql", migrator.NewRawSQLMigration("").
Mysql("ALTER TABLE alert_configuration MODIFY alertmanager_configuration MEDIUMTEXT;")) Mysql("ALTER TABLE alert_configuration MODIFY alertmanager_configuration MEDIUMTEXT;"))
mg.AddMigration("add column org_id in alert_configuration", migrator.NewAddColumnMigration(alertConfiguration, &migrator.Column{
Name: "org_id", Type: migrator.DB_BigInt, Nullable: false, Default: "0",
}))
// add index on org_id
mg.AddMigration("add index in alert_configuration table on org_id column", migrator.NewAddIndexMigration(alertConfiguration, &migrator.Index{
Cols: []string{"org_id"},
}))
} }
func AddAlertAdminConfigMigrations(mg *migrator.Migrator) { func AddAlertAdminConfigMigrations(mg *migrator.Migrator) {
+132 -51
View File
@@ -23,6 +23,8 @@ var migTitle = "move dashboard alerts to unified alerting"
var rmMigTitle = "remove unified alerting data" var rmMigTitle = "remove unified alerting data"
const clearMigrationEntryTitle = "clear migration entry %q"
type MigrationError struct { type MigrationError struct {
AlertId int64 AlertId int64
Err error Err error
@@ -49,19 +51,23 @@ func AddDashAlertMigration(mg *migrator.Migrator) {
case ngEnabled && !migrationRun: case ngEnabled && !migrationRun:
// Remove the migration entry that removes all unified alerting data. This is so when the feature // Remove the migration entry that removes all unified alerting data. This is so when the feature
// flag is removed in future the "remove unified alerting data" migration will be run again. // flag is removed in future the "remove unified alerting data" migration will be run again.
err = mg.ClearMigrationEntry(rmMigTitle) mg.AddMigration(fmt.Sprintf(clearMigrationEntryTitle, rmMigTitle), &clearMigrationEntry{
migrationID: rmMigTitle,
})
if err != nil { if err != nil {
mg.Logger.Error("alert migration error: could not clear alert migration for removing data", "error", err) mg.Logger.Error("alert migration error: could not clear alert migration for removing data", "error", err)
} }
mg.AddMigration(migTitle, &migration{ mg.AddMigration(migTitle, &migration{
seenChannelUIDs: make(map[string]struct{}), seenChannelUIDs: make(map[string]struct{}),
migratedChannels: make(map[*notificationChannel]struct{}), migratedChannelsPerOrg: make(map[int64]map[*notificationChannel]struct{}),
portedChannelGroups: make(map[string]string), portedChannelGroupsPerOrg: make(map[int64]map[string]string),
}) })
case !ngEnabled && migrationRun: case !ngEnabled && migrationRun:
// Remove the migration entry that creates unified alerting data. This is so when the feature // Remove the migration entry that creates unified alerting data. This is so when the feature
// flag is enabled in the future the migration "move dashboard alerts to unified alerting" will be run again. // flag is enabled in the future the migration "move dashboard alerts to unified alerting" will be run again.
err = mg.ClearMigrationEntry(migTitle) mg.AddMigration(fmt.Sprintf(clearMigrationEntryTitle, migTitle), &clearMigrationEntry{
migrationID: migTitle,
})
if err != nil { if err != nil {
mg.Logger.Error("alert migration error: could not clear dashboard alert migration", "error", err) mg.Logger.Error("alert migration error: could not clear dashboard alert migration", "error", err)
} }
@@ -69,17 +75,84 @@ func AddDashAlertMigration(mg *migrator.Migrator) {
} }
} }
// RerunDashAlertMigration force the dashboard alert migration to run
// to make sure that the Alertmanager configurations will be created for each organisation
func RerunDashAlertMigration(mg *migrator.Migrator) {
logs, err := mg.GetMigrationLog()
if err != nil {
mg.Logger.Crit("alert migration failure: could not get migration log", "error", err)
os.Exit(1)
}
cloneMigTitle := fmt.Sprintf("clone %s", migTitle)
cloneRmMigTitle := fmt.Sprintf("clone %s", rmMigTitle)
_, migrationRun := logs[cloneMigTitle]
ngEnabled := mg.Cfg.IsNgAlertEnabled()
switch {
case ngEnabled && !migrationRun:
// Removes all unified alerting data. It is not recorded so when the feature
// flag is removed in future the "clone remove unified alerting data" migration will be run again.
mg.AddMigration(cloneRmMigTitle, &rmMigrationWithoutLogging{})
mg.AddMigration(cloneMigTitle, &migration{
seenChannelUIDs: make(map[string]struct{}),
migratedChannelsPerOrg: make(map[int64]map[*notificationChannel]struct{}),
portedChannelGroupsPerOrg: make(map[int64]map[string]string),
})
case !ngEnabled && migrationRun:
// Remove the migration entry that creates unified alerting data. This is so when the feature
// flag is enabled in the future the migration "move dashboard alerts to unified alerting" will be run again.
mg.AddMigration(fmt.Sprintf(clearMigrationEntryTitle, cloneMigTitle), &clearMigrationEntry{
migrationID: cloneMigTitle,
})
if err != nil {
mg.Logger.Error("alert migration error: could not clear clone dashboard alert migration", "error", err)
}
// Removes all unified alerting data. It is not recorded so when the feature
// flag is enabled in future the "clone remove unified alerting data" migration will be run again.
mg.AddMigration(cloneRmMigTitle, &rmMigrationWithoutLogging{})
}
}
// clearMigrationEntry removes an entry fromt the migration_log table.
// This migration is not recorded in the migration_log so that it can re-run several times.
type clearMigrationEntry struct {
migrator.MigrationBase
migrationID string
}
func (m *clearMigrationEntry) SQL(dialect migrator.Dialect) string {
return "clear migration entry code migration"
}
func (m *clearMigrationEntry) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
_, err := sess.SQL(`DELETE from migration_log where migration_id = ?`, m.migrationID).Query()
if err != nil {
return fmt.Errorf("failed to clear migration entry %v: %w", m.migrationID, err)
}
return nil
}
func (m *clearMigrationEntry) SkipMigrationLog() bool {
return true
}
type migration struct { type migration struct {
migrator.MigrationBase migrator.MigrationBase
// session and mg are attached for convenience. // session and mg are attached for convenience.
sess *xorm.Session sess *xorm.Session
mg *migrator.Migrator mg *migrator.Migrator
seenChannelUIDs map[string]struct{} seenChannelUIDs map[string]struct{}
migratedChannels map[*notificationChannel]struct{} migratedChannelsPerOrg map[int64]map[*notificationChannel]struct{}
silences []*pb.MeshSilence silences []*pb.MeshSilence
portedChannelGroups map[string]string // Channel group key -> receiver name. portedChannelGroupsPerOrg map[int64]map[string]string // Org -> Channel group key -> receiver name.
lastReceiverID int // For the auto generated receivers. lastReceiverID int // For the auto generated receivers.
} }
func (m *migration) SQL(dialect migrator.Dialect) string { func (m *migration) SQL(dialect migrator.Dialect) string {
@@ -108,13 +181,13 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
} }
// allChannels: channelUID -> channelConfig // allChannels: channelUID -> channelConfig
allChannels, defaultChannels, err := m.getNotificationChannelMap() allChannelsPerOrg, defaultChannelsPerOrg, err := m.getNotificationChannelMap()
if err != nil { if err != nil {
return err return err
} }
amConfig := PostableUserConfig{} amConfigPerOrg := make(amConfigsPerOrg, len(allChannelsPerOrg))
err = m.addDefaultChannels(&amConfig, allChannels, defaultChannels) err = m.addDefaultChannels(amConfigPerOrg, allChannelsPerOrg, defaultChannelsPerOrg)
if err != nil { if err != nil {
return err return err
} }
@@ -144,26 +217,11 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
} }
// get folder if exists // get folder if exists
folder := dashboard{} folder, err := m.getFolder(dash, da)
if dash.FolderId > 0 { if err != nil {
exists, err := m.sess.Where("id=?", dash.FolderId).Get(&folder) return MigrationError{
if err != nil { Err: err,
return MigrationError{ AlertId: da.Id,
Err: fmt.Errorf("failed to get folder %d: %w", dash.FolderId, err),
AlertId: da.Id,
}
}
if !exists {
return MigrationError{
Err: fmt.Errorf("folder with id %v not found", dash.FolderId),
AlertId: da.Id,
}
}
if !folder.IsFolder {
return MigrationError{
Err: fmt.Errorf("id %v is a dashboard not a folder", dash.FolderId),
AlertId: da.Id,
}
} }
} }
@@ -220,8 +278,12 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
return err return err
} }
if err := m.updateReceiverAndRoute(allChannels, defaultChannels, da, rule, &amConfig); err != nil { if _, ok := amConfigPerOrg[rule.OrgID]; !ok {
return err m.mg.Logger.Info("no configuration found", "org", rule.OrgID)
} else {
if err := m.updateReceiverAndRoute(allChannelsPerOrg, defaultChannelsPerOrg, da, rule, amConfigPerOrg[rule.OrgID]); err != nil {
return err
}
} }
if strings.HasPrefix(mg.Dialect.DriverName(), migrator.Postgres) { if strings.HasPrefix(mg.Dialect.DriverName(), migrator.Postgres) {
@@ -234,8 +296,8 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
} }
if err != nil { if err != nil {
// TODO better error handling, if constraint // TODO better error handling, if constraint
rule.Title += fmt.Sprintf(" %v", rule.Uid) rule.Title += fmt.Sprintf(" %v", rule.UID)
rule.RuleGroup += fmt.Sprintf(" %v", rule.Uid) rule.RuleGroup += fmt.Sprintf(" %v", rule.UID)
_, err = m.sess.Insert(rule) _, err = m.sess.Insert(rule)
if err != nil { if err != nil {
@@ -250,24 +312,26 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
} }
} }
// Create a separate receiver for all the unmigrated channels. for orgID, amConfig := range amConfigPerOrg {
err = m.addUnmigratedChannels(&amConfig, allChannels, defaultChannels) // Create a separate receiver for all the unmigrated channels.
if err != nil { err = m.addUnmigratedChannels(orgID, amConfig, allChannelsPerOrg[orgID], defaultChannelsPerOrg[orgID])
return err if err != nil {
} return err
}
if err := m.writeAlertmanagerConfig(&amConfig, allChannels); err != nil { if err := m.writeAlertmanagerConfig(orgID, amConfig, allChannelsPerOrg[orgID]); err != nil {
return err return err
} }
if err := m.writeSilencesFile(); err != nil { if err := m.writeSilencesFile(orgID); err != nil {
m.mg.Logger.Error("alert migration error: failed to write silence file", "err", err) m.mg.Logger.Error("alert migration error: failed to write silence file", "err", err)
}
} }
return nil return nil
} }
func (m *migration) writeAlertmanagerConfig(amConfig *PostableUserConfig, allChannels map[interface{}]*notificationChannel) error { func (m *migration) writeAlertmanagerConfig(orgID int64, amConfig *PostableUserConfig, allChannels map[interface{}]*notificationChannel) error {
if len(allChannels) == 0 { if len(allChannels) == 0 {
// No channels, hence don't require Alertmanager config. // No channels, hence don't require Alertmanager config.
m.mg.Logger.Info("alert migration: no notification channel found, skipping Alertmanager config") m.mg.Logger.Info("alert migration: no notification channel found, skipping Alertmanager config")
@@ -288,6 +352,7 @@ func (m *migration) writeAlertmanagerConfig(amConfig *PostableUserConfig, allCha
// Since we are migration for a snapshot of the code, it is always going to migrate to // Since we are migration for a snapshot of the code, it is always going to migrate to
// the v1 config. // the v1 config.
ConfigurationVersion: "v1", ConfigurationVersion: "v1",
OrgID: orgID,
}) })
if err != nil { if err != nil {
return err return err
@@ -297,13 +362,15 @@ func (m *migration) writeAlertmanagerConfig(amConfig *PostableUserConfig, allCha
} }
type AlertConfiguration struct { type AlertConfiguration struct {
ID int64 `xorm:"pk autoincr 'id'"` ID int64 `xorm:"pk autoincr 'id'"`
OrgID int64 `xorm:"org_id"`
AlertmanagerConfiguration string AlertmanagerConfiguration string
ConfigurationVersion string ConfigurationVersion string
CreatedAt int64 `xorm:"created"` CreatedAt int64 `xorm:"created"`
} }
// rmMigration removes Grafana 8 alert data
type rmMigration struct { type rmMigration struct {
migrator.MigrationBase migrator.MigrationBase
} }
@@ -343,9 +410,23 @@ func (m *rmMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
return err return err
} }
if err := os.RemoveAll(silencesFileName(mg)); err != nil { files, err := getSilenceFileNamesForAllOrgs(mg)
mg.Logger.Error("alert migration error: failed to remove silence file", "err", err) if err != nil {
return err
}
for _, f := range files {
if err := os.Remove(f); err != nil {
mg.Logger.Error("alert migration error: failed to remove silence file", "file", f, "err", err)
}
} }
return nil return nil
} }
// rmMigrationWithoutLogging is similar migration to rmMigration
// but is not recorded in the migration_log table so that it can rerun in the future
type rmMigrationWithoutLogging = rmMigration
func (m *rmMigrationWithoutLogging) SkipMigrationLog() bool {
return true
}
@@ -21,6 +21,10 @@ func (m *MigrationBase) GetCondition() MigrationCondition {
return m.Condition return m.Condition
} }
func (m *MigrationBase) SkipMigrationLog() bool {
return false
}
type RawSQLMigration struct { type RawSQLMigration struct {
MigrationBase MigrationBase
+7 -13
View File
@@ -108,13 +108,17 @@ func (mg *Migrator) Start() error {
if err != nil { if err != nil {
mg.Logger.Error("Exec failed", "error", err, "sql", sql) mg.Logger.Error("Exec failed", "error", err, "sql", sql)
record.Error = err.Error() record.Error = err.Error()
if _, err := sess.Insert(&record); err != nil { if !m.SkipMigrationLog() {
return err if _, err := sess.Insert(&record); err != nil {
return err
}
} }
return err return err
} }
record.Success = true record.Success = true
_, err = sess.Insert(&record) if !m.SkipMigrationLog() {
_, err = sess.Insert(&record)
}
if err == nil { if err == nil {
migrationsPerformed++ migrationsPerformed++
} }
@@ -171,16 +175,6 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
return nil return nil
} }
func (mg *Migrator) ClearMigrationEntry(id string) error {
sess := mg.x.NewSession()
defer sess.Close()
_, err := sess.SQL(`DELETE from migration_log where migration_id = ?`, id).Query()
if err != nil {
return fmt.Errorf("failed to clear migration entry %v: %w", id, err)
}
return nil
}
type dbTransactionFunc func(sess *xorm.Session) error type dbTransactionFunc func(sess *xorm.Session) error
func (mg *Migrator) InTransaction(callback dbTransactionFunc) error { func (mg *Migrator) InTransaction(callback dbTransactionFunc) error {
+4
View File
@@ -19,6 +19,10 @@ type Migration interface {
Id() string Id() string
SetId(string) SetId(string)
GetCondition() MigrationCondition GetCondition() MigrationCondition
// SkipMigrationLog is used by dashboard alert migration to Grafana 8 Alerts
// for skipping recording it in the migration_log so that it can run several times.
// For all the other migrations it should be false.
SkipMigrationLog() bool
} }
type CodeMigration interface { type CodeMigration interface {
+14
View File
@@ -405,6 +405,9 @@ type Cfg struct {
// Geomap base layer config // Geomap base layer config
GeomapDefaultBaseLayerConfig map[string]interface{} GeomapDefaultBaseLayerConfig map[string]interface{}
GeomapEnableCustomBaseLayers bool GeomapEnableCustomBaseLayers bool
// Unified Alerting
AdminConfigPollInterval time.Duration
} }
// IsLiveConfigEnabled returns true if live should be able to save configs to SQL tables // IsLiveConfigEnabled returns true if live should be able to save configs to SQL tables
@@ -898,6 +901,10 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
return err return err
} }
if err := cfg.readUnifiedAlertingSettings(iniFile); err != nil {
return err
}
explore := iniFile.Section("explore") explore := iniFile.Section("explore")
ExploreEnabled = explore.Key("enabled").MustBool(true) ExploreEnabled = explore.Key("enabled").MustBool(true)
@@ -1349,6 +1356,13 @@ func readRenderingSettings(iniFile *ini.File, cfg *Cfg) error {
return nil return nil
} }
func (cfg *Cfg) readUnifiedAlertingSettings(iniFile *ini.File) error {
ua := iniFile.Section("unified_alerting")
s := ua.Key("admin_config_poll_interval_seconds").MustInt(60)
cfg.AdminConfigPollInterval = time.Second * time.Duration(s)
return nil
}
func readAlertingSettings(iniFile *ini.File) error { func readAlertingSettings(iniFile *ini.File) error {
alerting := iniFile.Section("alerting") alerting := iniFile.Section("alerting")
AlertingEnabled = alerting.Key("enabled").MustBool(true) AlertingEnabled = alerting.Key("enabled").MustBool(true)
+11
View File
@@ -31,28 +31,39 @@ func normalizeAzureCloud(cloudName string) string {
switch strings.ToLower(cloudName) { switch strings.ToLower(cloudName) {
// Public // Public
case "azurecloud": case "azurecloud":
fallthrough
case "azurepublic": case "azurepublic":
fallthrough
case "azurepubliccloud": case "azurepubliccloud":
fallthrough
case "public": case "public":
return AzurePublic return AzurePublic
// China // China
case "azurechina": case "azurechina":
fallthrough
case "azurechinacloud": case "azurechinacloud":
fallthrough
case "china": case "china":
return AzureChina return AzureChina
// US Government // US Government
case "azureusgovernment": case "azureusgovernment":
fallthrough
case "azureusgovernmentcloud": case "azureusgovernmentcloud":
fallthrough
case "usgov": case "usgov":
fallthrough
case "usgovernment": case "usgovernment":
return AzureUSGovernment return AzureUSGovernment
// Germany // Germany
case "azuregermancloud": case "azuregermancloud":
fallthrough
case "azuregermany": case "azuregermany":
fallthrough
case "german": case "german":
fallthrough
case "germany": case "germany":
return AzureGermany return AzureGermany
} }
+65
View File
@@ -0,0 +1,65 @@
package setting
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAzureSettings(t *testing.T) {
t.Run("cloud name", func(t *testing.T) {
testCases := []struct {
name string
configuredValue string
resolvedValue string
}{
{
name: "should be Public if not set",
configuredValue: "",
resolvedValue: AzurePublic,
},
{
name: "should be Public if set to Public",
configuredValue: AzurePublic,
resolvedValue: AzurePublic,
},
{
name: "should be Public if set to Public using alternative name",
configuredValue: "AzurePublicCloud",
resolvedValue: AzurePublic,
},
{
name: "should be China if set to China",
configuredValue: AzureChina,
resolvedValue: AzureChina,
},
{
name: "should be US Government if set to US Government using alternative name",
configuredValue: "usgov",
resolvedValue: AzureUSGovernment,
},
{
name: "should be same as set if not known",
configuredValue: "Custom123",
resolvedValue: "Custom123",
},
}
for _, c := range testCases {
t.Run(c.name, func(t *testing.T) {
cfg := NewCfg()
azureSection, err := cfg.Raw.NewSection("azure")
require.NoError(t, err)
_, err = azureSection.NewKey("cloud", c.configuredValue)
require.NoError(t, err)
cfg.readAzureSettings()
require.NotNil(t, cfg.Azure)
assert.Equal(t, c.resolvedValue, cfg.Azure.Cloud)
})
}
})
}
@@ -0,0 +1,156 @@
package alerting
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/models"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/tests/testinfra"
)
func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{"ngalert"},
DisableAnonymous: true,
NGAlertAdminConfigIntervalSeconds: 2,
})
s := testinfra.SetUpDatabase(t, dir)
// override bus to get the GetSignedInUserQuery handler
s.Bus = bus.GetBus()
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, s)
// Create a user to make authenticated requests
createUser(t, s, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_ADMIN),
Login: "grafana",
Password: "password",
})
// Create a couple of "fake" Alertmanagers
fakeAM1 := schedule.NewFakeExternalAlertmanager(t)
fakeAM2 := schedule.NewFakeExternalAlertmanager(t)
// Now, let's test the configuration API.
{
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/admin_config", grafanaListedAddr)
resp := getRequest(t, alertsURL, http.StatusNotFound) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, string(b), "{\"message\": \"no admin configuration available\"}")
}
// Now, lets re-set external Alertmanagers.
{
ac := apimodels.PostableNGalertConfig{
Alertmanagers: []string{fakeAM1.URL(), fakeAM2.URL()},
}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(&ac)
require.NoError(t, err)
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/admin_config", grafanaListedAddr)
resp := postRequest(t, alertsURL, buf.String(), http.StatusCreated) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, string(b), "{\"message\": \"admin configuration updated\"}")
}
// If we get the configuration again, it shows us what we've set.
{
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/admin_config", grafanaListedAddr)
resp := getRequest(t, alertsURL, http.StatusOK) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, string(b), fmt.Sprintf("{\"alertmanagers\":[\"%s\",\"%s\"]}\n", fakeAM1.URL(), fakeAM2.URL()))
}
// With the configuration set, we should eventually discover those Alertmanagers set.
{
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/alertmanagers", grafanaListedAddr)
require.Eventually(t, func() bool {
resp := getRequest(t, alertsURL, http.StatusOK) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
var alertmanagers apimodels.GettableAlertmanagers
require.NoError(t, json.Unmarshal(b, &alertmanagers))
return len(alertmanagers.Data.Active) == 2
}, 80*time.Second, 4*time.Second)
}
// Now, let's set an alert that should fire as quickly as possible.
{
// create the namespace we'll save our alerts to
_, err := createFolder(t, s, 0, "default")
require.NoError(t, err)
interval, err := model.ParseDuration("10s")
require.NoError(t, err)
rules := apimodels.PostableRuleGroupConfig{
Name: "arulegroup",
Interval: interval,
Rules: []apimodels.PostableExtendedRuleNode{
{
ApiRuleNode: &apimodels.ApiRuleNode{
For: interval,
Labels: map[string]string{"label1": "val1"},
Annotations: map[string]string{"annotation1": "val1"},
},
// this rule does not explicitly set no data and error states
// therefore it should get the default values
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: "AlwaysFiring",
Condition: "A",
Data: []ngmodels.AlertQuery{
{
RefID: "A",
RelativeTimeRange: ngmodels.RelativeTimeRange{
From: ngmodels.Duration(time.Duration(5) * time.Hour),
To: ngmodels.Duration(time.Duration(3) * time.Hour),
},
DatasourceUID: "-100",
Model: json.RawMessage(`{
"type": "math",
"expression": "2 + 3 > 1"
}`),
},
},
},
},
},
}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err = enc.Encode(&rules)
require.NoError(t, err)
ruleURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr)
// nolint
_ = postRequest(t, ruleURL, buf.String(), http.StatusAccepted)
}
//Eventually, our Alertmanagers should receiver the alert.
{
require.Eventually(t, func() bool {
return fakeAM1.AlertsCount() == 1 && fakeAM2.AlertsCount() == 1
}, 60*time.Second, 5*time.Second)
}
}
@@ -4,8 +4,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"testing" "testing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testinfra"
@@ -16,12 +18,34 @@ import (
func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { func TestAlertmanagerConfigurationIsTransactional(t *testing.T) {
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{"ngalert"}, EnableFeatureToggles: []string{"ngalert"},
AnonymousUserRole: models.ROLE_EDITOR, DisableAnonymous: true,
}) })
store := testinfra.SetUpDatabase(t, dir) store := testinfra.SetUpDatabase(t, dir)
// override bus to get the GetSignedInUserQuery handler
store.Bus = bus.GetBus()
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
alertConfigURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr)
// create user under main organisation
userID := createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "editor",
Login: "editor",
})
// create another organisation
orgID := createOrg(t, store, "another org", userID)
// create user under different organisation
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "editor-42",
Login: "editor-42",
OrgId: orgID,
})
// editor from main organisation requests configuration
alertConfigURL := fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr)
// On a blank start with no configuration, it saves and delivers the default configuration. // On a blank start with no configuration, it saves and delivers the default configuration.
{ {
@@ -66,17 +90,48 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) {
resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint
require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body)) require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body))
} }
// editor42 from organisation 42 posts configuration
alertConfigURL = fmt.Sprintf("http://editor-42:editor-42@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr)
// Post the alertmanager config.
{
mockChannel := newMockNotificationChannel(t, grafanaListedAddr)
amConfig := getAlertmanagerConfig(mockChannel.server.Addr)
postRequest(t, alertConfigURL, amConfig, http.StatusAccepted) // nolint
// Verifying that the new configuration is returned
resp := getRequest(t, alertConfigURL, http.StatusOK) // nolint
b := getBody(t, resp.Body)
re := regexp.MustCompile(`"uid":"([\w|-]*)"`)
e := getExpAlertmanagerConfigFromAPI(mockChannel.server.Addr)
require.JSONEq(t, e, string(re.ReplaceAll([]byte(b), []byte(`"uid":""`))))
}
// verify that main organisation still gets the default configuration
alertConfigURL = fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr)
{
resp := getRequest(t, alertConfigURL, http.StatusOK) // nolint
require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body))
}
} }
func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) { func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) {
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{"ngalert"}, EnableFeatureToggles: []string{"ngalert"},
AnonymousUserRole: models.ROLE_EDITOR, DisableAnonymous: true,
}) })
store := testinfra.SetUpDatabase(t, dir) store := testinfra.SetUpDatabase(t, dir)
// override bus to get the GetSignedInUserQuery handler
store.Bus = bus.GetBus()
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
alertConfigURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "editor",
Login: "editor",
})
alertConfigURL := fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr)
generatedUID := "" generatedUID := ""
// create a new configuration that has a secret // create a new configuration that has a secret
+71 -23
View File
@@ -39,9 +39,21 @@ func TestAMConfigAccess(t *testing.T) {
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
// Create a users to make authenticated requests // Create a users to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer")) createUser(t, store, models.CreateUserCommand{
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor")) DefaultOrgRole: string(models.ROLE_VIEWER),
require.NoError(t, createUser(t, store, models.ROLE_ADMIN, "admin", "admin")) Password: "viewer",
Login: "viewer",
})
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "editor",
Login: "editor",
})
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_ADMIN),
Password: "admin",
Login: "admin",
})
type testCase struct { type testCase struct {
desc string desc string
@@ -402,7 +414,11 @@ func TestAlertAndGroupsQuery(t *testing.T) {
} }
// Create a user to make authenticated requests // Create a user to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
// invalid credentials request to get the alerts should fail // invalid credentials request to get the alerts should fail
{ {
@@ -554,9 +570,21 @@ func TestRulerAccess(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Create a users to make authenticated requests // Create a users to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer")) createUser(t, store, models.CreateUserCommand{
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor")) DefaultOrgRole: string(models.ROLE_VIEWER),
require.NoError(t, createUser(t, store, models.ROLE_ADMIN, "admin", "admin")) Password: "viewer",
Login: "viewer",
})
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "editor",
Login: "editor",
})
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_ADMIN),
Password: "admin",
Login: "admin",
})
// Now, let's test the access policies. // Now, let's test the access policies.
testCases := []struct { testCases := []struct {
@@ -668,8 +696,16 @@ func TestDeleteFolderWithRules(t *testing.T) {
namespaceUID, err := createFolder(t, store, 0, "default") namespaceUID, err := createFolder(t, store, 0, "default")
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer")) createUser(t, store, models.CreateUserCommand{
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor")) DefaultOrgRole: string(models.ROLE_VIEWER),
Password: "viewer",
Login: "viewer",
})
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "editor",
Login: "editor",
})
createRule(t, grafanaListedAddr, "default", "editor", "editor") createRule(t, grafanaListedAddr, "default", "editor", "editor")
@@ -815,12 +851,14 @@ func TestAlertRuleCRUD(t *testing.T) {
store.Bus = bus.GetBus() store.Bus = bus.GetBus()
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
err := createUser(t, store, models.ROLE_EDITOR, "grafana", "password") createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
require.NoError(t, err) Password: "password",
Login: "grafana",
})
// Create the namespace we'll save our alerts to. // Create the namespace we'll save our alerts to.
_, err = createFolder(t, store, 0, "default") _, err := createFolder(t, store, 0, "default")
require.NoError(t, err) require.NoError(t, err)
interval, err := model.ParseDuration("1m") interval, err := model.ParseDuration("1m")
@@ -1827,7 +1865,11 @@ func TestQuota(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Create a user to make authenticated requests // Create a user to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
interval, err := model.ParseDuration("1m") interval, err := model.ParseDuration("1m")
require.NoError(t, err) require.NoError(t, err)
@@ -1921,7 +1963,11 @@ func TestEval(t *testing.T) {
store.Bus = bus.GetBus() store.Bus = bus.GetBus()
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
// Create the namespace we'll save our alerts to. // Create the namespace we'll save our alerts to.
_, err := createFolder(t, store, 0, "default") _, err := createFolder(t, store, 0, "default")
@@ -2338,16 +2384,18 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st
return string(json), m return string(json), m
} }
func createUser(t *testing.T, store *sqlstore.SQLStore, role models.RoleType, username, password string) error { func createUser(t *testing.T, store *sqlstore.SQLStore, cmd models.CreateUserCommand) int64 {
t.Helper() t.Helper()
cmd := models.CreateUserCommand{ u, err := store.CreateUser(context.Background(), cmd)
Login: username, require.NoError(t, err)
Password: password, return u.Id
DefaultOrgRole: string(role), }
}
_, err := store.CreateUser(context.Background(), cmd) func createOrg(t *testing.T, store *sqlstore.SQLStore, name string, userID int64) int64 {
return err org, err := store.CreateOrgWithMember(name, userID)
require.NoError(t, err)
return org.Id
} }
func getLongString(t *testing.T, n int) string { func getLongString(t *testing.T, n int) string {
@@ -24,7 +24,11 @@ func TestAvailableChannels(t *testing.T) {
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
// Create a user to make authenticated requests // Create a user to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alert-notifiers", grafanaListedAddr) alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alert-notifiers", grafanaListedAddr)
// nolint:gosec // nolint:gosec
@@ -68,7 +68,11 @@ func TestNotificationChannels(t *testing.T) {
bus.AddHandlerCtx("", mockEmail.sendEmailCommandHandlerSync) bus.AddHandlerCtx("", mockEmail.sendEmailCommandHandlerSync)
// Create a user to make authenticated requests // Create a user to make authenticated requests
require.NoError(t, createUser(t, s, models.ROLE_EDITOR, "grafana", "password")) createUser(t, s, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
{ {
// There are no notification channel config initially - so it returns the default configuration. // There are no notification channel config initially - so it returns the default configuration.
+10 -2
View File
@@ -34,7 +34,11 @@ func TestPrometheusRules(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Create a user to make authenticated requests // Create a user to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
interval, err := model.ParseDuration("10s") interval, err := model.ParseDuration("10s")
require.NoError(t, err) require.NoError(t, err)
@@ -270,7 +274,11 @@ func TestPrometheusRulesPermissions(t *testing.T) {
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
// Create a user to make authenticated requests // Create a user to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
// Create a namespace under default organisation (orgID = 1) where we'll save some alerts. // Create a namespace under default organisation (orgID = 1) where we'll save some alerts.
_, err := createFolder(t, store, 0, "folder1") _, err := createFolder(t, store, 0, "folder1")
+10 -2
View File
@@ -31,7 +31,11 @@ func TestAlertRulePermissions(t *testing.T) {
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
// Create a user to make authenticated requests // Create a user to make authenticated requests
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "password",
Login: "grafana",
})
// Create the namespace we'll save our alerts to. // Create the namespace we'll save our alerts to.
_, err := createFolder(t, store, 0, "folder1") _, err := createFolder(t, store, 0, "folder1")
@@ -320,7 +324,11 @@ func TestAlertRuleConflictingTitle(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Create user // Create user
require.NoError(t, createUser(t, store, models.ROLE_ADMIN, "admin", "admin")) createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_ADMIN),
Password: "admin",
Login: "admin",
})
interval, err := model.ParseDuration("1m") interval, err := model.ParseDuration("1m")
require.NoError(t, err) require.NoError(t, err)
+1 -1
View File
@@ -56,10 +56,10 @@ func postRequest(t *testing.T, url string, body string, expStatusCode int) *http
buf := bytes.NewReader([]byte(body)) buf := bytes.NewReader([]byte(body))
// nolint:gosec // nolint:gosec
resp, err := http.Post(url, "application/json", buf) resp, err := http.Post(url, "application/json", buf)
require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
}) })
require.NoError(t, err)
if expStatusCode != resp.StatusCode { if expStatusCode != resp.StatusCode {
b, err := ioutil.ReadAll(resp.Body) b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
+16 -8
View File
@@ -224,6 +224,13 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
_, err = featureSection.NewKey("enable", strings.Join(o.EnableFeatureToggles, " ")) _, err = featureSection.NewKey("enable", strings.Join(o.EnableFeatureToggles, " "))
require.NoError(t, err) require.NoError(t, err)
} }
if o.NGAlertAdminConfigIntervalSeconds != 0 {
ngalertingSection, err := cfg.NewSection("ngalerting")
require.NoError(t, err)
_, err = ngalertingSection.NewKey("admin_config_poll_interval_seconds", fmt.Sprintf("%d", o.NGAlertAdminConfigIntervalSeconds))
require.NoError(t, err)
}
if o.AnonymousUserRole != "" { if o.AnonymousUserRole != "" {
_, err = anonSect.NewKey("org_role", string(o.AnonymousUserRole)) _, err = anonSect.NewKey("org_role", string(o.AnonymousUserRole))
require.NoError(t, err) require.NoError(t, err)
@@ -265,12 +272,13 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
} }
type GrafanaOpts struct { type GrafanaOpts struct {
EnableCSP bool EnableCSP bool
EnableFeatureToggles []string EnableFeatureToggles []string
AnonymousUserRole models.RoleType NGAlertAdminConfigIntervalSeconds int
EnableQuota bool AnonymousUserRole models.RoleType
DisableAnonymous bool EnableQuota bool
CatalogAppEnabled bool DisableAnonymous bool
ViewersCanEdit bool CatalogAppEnabled bool
PluginAdminEnabled bool ViewersCanEdit bool
PluginAdminEnabled bool
} }
@@ -179,15 +179,38 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query *
if err != nil { if err != nil {
return dataResponseErrorWithExecuted(err) return dataResponseErrorWithExecuted(err)
} }
if frame.Meta == nil {
frame.Meta = &data.FrameMeta{}
}
frame.Meta.ExecutedQueryString = req.URL.RawQuery
dataResponse.Frames = data.Frames{frame} azurePortalUrl, err := getAzurePortalUrl(dsInfo.Cloud)
if err != nil {
return dataResponseErrorWithExecuted(err)
}
url := azurePortalUrl + "/#blade/HubsExtension/ArgQueryBlade/query/" + url.PathEscape(query.InterpolatedQuery)
frameWithLink := addConfigData(*frame, url)
if frameWithLink.Meta == nil {
frameWithLink.Meta = &data.FrameMeta{}
}
frameWithLink.Meta.ExecutedQueryString = req.URL.RawQuery
dataResponse.Frames = data.Frames{&frameWithLink}
return dataResponse return dataResponse
} }
func addConfigData(frame data.Frame, dl string) data.Frame {
for i := range frame.Fields {
if frame.Fields[i].Config == nil {
frame.Fields[i].Config = &data.FieldConfig{}
}
deepLink := data.DataLink{
Title: "View in Azure Portal",
TargetBlank: true,
URL: dl,
}
frame.Fields[i].Config.Links = append(frame.Fields[i].Config.Links, deepLink)
}
return frame
}
func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo datasourceInfo, reqBody []byte, url string) (*http.Request, error) { func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo datasourceInfo, reqBody []byte, url string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody)) req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
if err != nil { if err != nil {
@@ -228,3 +251,18 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az
return data, nil return data, nil
} }
func getAzurePortalUrl(azureCloud string) (string, error) {
switch azureCloud {
case setting.AzurePublic:
return "https://portal.azure.com", nil
case setting.AzureChina:
return "https://portal.azure.cn", nil
case setting.AzureUSGovernment:
return "https://portal.azure.us", nil
case setting.AzureGermany:
return "https://portal.microsoftazure.de", nil
default:
return "", fmt.Errorf("the cloud is not supported")
}
}
@@ -10,8 +10,11 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -110,3 +113,42 @@ func TestAzureResourceGraphCreateRequest(t *testing.T) {
}) })
} }
} }
func TestAddConfigData(t *testing.T) {
field := data.Field{}
dataLink := data.DataLink{Title: "View in Azure Portal", TargetBlank: true, URL: "http://ds"}
frame := data.Frame{
Fields: []*data.Field{&field},
}
frameWithLink := addConfigData(frame, "http://ds")
expectedFrameWithLink := data.Frame{
Fields: []*data.Field{
{
Config: &data.FieldConfig{
Links: []data.DataLink{dataLink},
},
},
},
}
if !cmp.Equal(frameWithLink, expectedFrameWithLink, data.FrameTestCompareOptions()...) {
t.Errorf("unexpepcted frame: %v", cmp.Diff(frameWithLink, expectedFrameWithLink, data.FrameTestCompareOptions()...))
}
}
func TestGetAzurePortalUrl(t *testing.T) {
clouds := []string{setting.AzurePublic, setting.AzureChina, setting.AzureUSGovernment, setting.AzureGermany}
expectedAzurePortalUrl := map[string]interface{}{
setting.AzurePublic: "https://portal.azure.com",
setting.AzureChina: "https://portal.azure.cn",
setting.AzureUSGovernment: "https://portal.azure.us",
setting.AzureGermany: "https://portal.microsoftazure.de",
}
for _, cloud := range clouds {
azurePortalUrl, err := getAzurePortalUrl(cloud)
if err != nil {
t.Errorf("The cloud not supported")
}
assert.Equal(t, expectedAzurePortalUrl[cloud], azurePortalUrl)
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ var metricsMap = map[string][]string{
"AWS/DAX": {"CPUUtilization", "NetworkPacketsIn", "NetworkPacketsOut", "GetItemRequestCount", "BatchGetItemRequestCount", "BatchWriteItemRequestCount", "DeleteItemRequestCount", "PutItemRequestCount", "UpdateItemRequestCount", "TransactWriteItemsCount", "TransactGetItemsCount", "ItemCacheHits", "ItemCacheMisses", "QueryCacheHits", "QueryCacheMisses", "ScanCacheHits", "ScanCacheMisses", "TotalRequestCount", "ErrorRequestCount", "FaultRequestCount", "FailedRequestCount", "QueryRequestCount", "ScanRequestCount", "ClientConnections", "EstimatedDbSize", "EvictedSize"}, "AWS/DAX": {"CPUUtilization", "NetworkPacketsIn", "NetworkPacketsOut", "GetItemRequestCount", "BatchGetItemRequestCount", "BatchWriteItemRequestCount", "DeleteItemRequestCount", "PutItemRequestCount", "UpdateItemRequestCount", "TransactWriteItemsCount", "TransactGetItemsCount", "ItemCacheHits", "ItemCacheMisses", "QueryCacheHits", "QueryCacheMisses", "ScanCacheHits", "ScanCacheMisses", "TotalRequestCount", "ErrorRequestCount", "FaultRequestCount", "FailedRequestCount", "QueryRequestCount", "ScanRequestCount", "ClientConnections", "EstimatedDbSize", "EvictedSize"},
"AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "PendingReplicationCount", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReplicationLatency", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "ThrottledRequests", "TimeToLiveDeletedItemCount", "UserErrors", "WriteThrottleEvents"}, "AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "PendingReplicationCount", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReplicationLatency", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "ThrottledRequests", "TimeToLiveDeletedItemCount", "UserErrors", "WriteThrottleEvents"},
"AWS/EBS": {"BurstBalance", "VolumeConsumedReadWriteOps", "VolumeIdleTime", "VolumeQueueLength", "VolumeReadBytes", "VolumeReadOps", "VolumeThroughputPercentage", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeWriteBytes", "VolumeWriteOps"}, "AWS/EBS": {"BurstBalance", "VolumeConsumedReadWriteOps", "VolumeIdleTime", "VolumeQueueLength", "VolumeReadBytes", "VolumeReadOps", "VolumeThroughputPercentage", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeWriteBytes", "VolumeWriteOps"},
"AWS/EC2": {"CPUCreditBalance", "CPUCreditUsage", "CPUSurplusCreditBalance", "CPUSurplusCreditsCharged", "CPUUtilization", "DiskReadBytes", "DiskReadOps", "DiskWriteBytes", "DiskWriteOps", "EBSByteBalance%", "EBSIOBalance%", "EBSReadBytes", "EBSReadOps", "EBSWriteBytes", "EBSWriteOps", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"}, "AWS/EC2": {"CPUCreditBalance", "CPUCreditUsage", "CPUSurplusCreditBalance", "CPUSurplusCreditsCharged", "CPUUtilization", "DiskReadBytes", "DiskReadOps", "DiskWriteBytes", "DiskWriteOps", "EBSByteBalance%", "EBSIOBalance%", "EBSReadBytes", "EBSReadOps", "EBSWriteBytes", "EBSWriteOps", "MetadataNoToken", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"},
"AWS/EC2/API": {"ClientErrors", "RequestLimitExceeded", "ServerErrors", "SuccessfulCalls"}, "AWS/EC2/API": {"ClientErrors", "RequestLimitExceeded", "ServerErrors", "SuccessfulCalls"},
"AWS/EC2CapacityReservations": {"AvailableInstanceCount", "InstanceUtilization", "TotalInstanceCount", "UsedInstanceCount"}, "AWS/EC2CapacityReservations": {"AvailableInstanceCount", "InstanceUtilization", "TotalInstanceCount", "UsedInstanceCount"},
"AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"}, "AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"},
+8
View File
@@ -37,5 +37,13 @@ func (a ByTime) Less(i, j int) bool {
return false return false
} }
if timeField.At(i).(*time.Time) == nil {
return false
}
if timeField.At(j).(*time.Time) == nil {
return true
}
return (timeField.At(i).(*time.Time)).Before(*timeField.At(j).(*time.Time)) return (timeField.At(i).(*time.Time)).Before(*timeField.At(j).(*time.Time))
} }
+69 -38
View File
@@ -12,46 +12,77 @@ import (
) )
func TestFrameSort(t *testing.T) { func TestFrameSort(t *testing.T) {
timeA, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 17:04:05.000") t.Run("sort simple frame", func(t *testing.T) {
timeB, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 16:04:05.000") timeA, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 17:04:05.000")
timeC, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 15:04:05.000") timeB, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 16:04:05.000")
timeVals := []*time.Time{ timeC, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 15:04:05.000")
&timeA, &timeB, &timeC, timeVals := []*time.Time{
} &timeA, &timeB, &timeC,
timeField := data.NewField("@timestamp", nil, timeVals) }
timeField := data.NewField("@timestamp", nil, timeVals)
stringField := data.NewField("line", nil, []*string{ stringField := data.NewField("line", nil, []*string{
aws.String("test message 1"), aws.String("test message 1"),
aws.String("test message 2"), aws.String("test message 2"),
aws.String("test message 3"), aws.String("test message 3"),
})
numberField := data.NewField("nums", nil, []*float64{
aws.Float64(20.0),
aws.Float64(50.0),
aws.Float64(17.0),
})
expectedDataframe := &data.Frame{
Name: "CloudWatchLogsResponse",
Fields: []*data.Field{
timeField,
stringField,
numberField,
},
}
sort.Sort(ByTime(*expectedDataframe))
for i := 1; i < timeField.Len(); i++ {
assert.True(t, timeField.At(i).(*time.Time).After(*(timeField.At(i - 1).(*time.Time))))
}
assert.Equal(t, *stringField.At(0).(*string), "test message 3")
assert.Equal(t, *stringField.At(1).(*string), "test message 2")
assert.Equal(t, *stringField.At(2).(*string), "test message 1")
assert.Equal(t, *numberField.At(0).(*float64), 17.0)
assert.Equal(t, *numberField.At(1).(*float64), 50.0)
assert.Equal(t, *numberField.At(2).(*float64), 20.0)
}) })
numberField := data.NewField("nums", nil, []*float64{ t.Run("sort with nil", func(t *testing.T) {
aws.Float64(20.0), timeA, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 17:04:05.000")
aws.Float64(50.0), timeB, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 16:04:05.000")
aws.Float64(17.0), timeVals := []*time.Time{
&timeA, &timeB, nil,
}
timeField := data.NewField("@timestamp", nil, timeVals)
stringField := data.NewField("line", nil, []*string{
aws.String("test message 1"),
aws.String("test message 2"),
aws.String("test message 3"),
})
frame := &data.Frame{
Name: "CloudWatchLogsResponse",
Fields: []*data.Field{
timeField,
stringField,
},
}
sort.Sort(ByTime(*frame))
assert.Equal(t, *stringField.At(0).(*string), "test message 2")
assert.Equal(t, *stringField.At(1).(*string), "test message 1")
assert.Equal(t, *stringField.At(2).(*string), "test message 3")
}) })
expectedDataframe := &data.Frame{
Name: "CloudWatchLogsResponse",
Fields: []*data.Field{
timeField,
stringField,
numberField,
},
}
sort.Sort(ByTime(*expectedDataframe))
for i := 1; i < timeField.Len(); i++ {
assert.True(t, timeField.At(i).(*time.Time).After(*(timeField.At(i - 1).(*time.Time))))
}
assert.Equal(t, *stringField.At(0).(*string), "test message 3")
assert.Equal(t, *stringField.At(1).(*string), "test message 2")
assert.Equal(t, *stringField.At(2).(*string), "test message 1")
assert.Equal(t, *numberField.At(0).(*float64), 17.0)
assert.Equal(t, *numberField.At(1).(*float64), 50.0)
assert.Equal(t, *numberField.At(2).(*float64), 20.0)
} }
+20 -4
View File
@@ -38,6 +38,7 @@ type DatasourceInfo struct {
HTTPClientOpts sdkhttpclient.Options HTTPClientOpts sdkhttpclient.Options
URL string URL string
HTTPMethod string HTTPMethod string
TimeInterval string
} }
func init() { func init() {
@@ -84,11 +85,26 @@ func newInstanceSettings() datasource.InstanceFactoryFunc {
return nil, errors.New("no http method provided") return nil, errors.New("no http method provided")
} }
// timeInterval can be a string or can be missing.
// if it is missing, we set it to empty-string
timeInterval := ""
timeIntervalJson := jsonData["timeInterval"]
if timeIntervalJson != nil {
// if it is not nil, it must be a string
timeInterval, ok = timeIntervalJson.(string)
if !ok {
return nil, errors.New("invalid time-interval provided")
}
}
mdl := DatasourceInfo{ mdl := DatasourceInfo{
ID: settings.ID, ID: settings.ID,
URL: settings.URL, URL: settings.URL,
HTTPClientOpts: httpCliOpts, HTTPClientOpts: httpCliOpts,
HTTPMethod: httpMethod, HTTPMethod: httpMethod,
TimeInterval: timeInterval,
} }
return mdl, nil return mdl, nil
} }
@@ -123,7 +139,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
Responses: backend.Responses{}, Responses: backend.Responses{},
} }
queries, err := s.parseQuery(req.Queries) queries, err := s.parseQuery(req.Queries, dsInfo)
if err != nil { if err != nil {
return &result, err return &result, err
} }
@@ -217,7 +233,7 @@ func formatLegend(metric model.Metric, query *PrometheusQuery) string {
return string(result) return string(result)
} }
func (s *Service) parseQuery(queries []backend.DataQuery) ( func (s *Service) parseQuery(queries []backend.DataQuery, dsInfo *DatasourceInfo) (
[]*PrometheusQuery, error) { []*PrometheusQuery, error) {
var intervalMode string var intervalMode string
var adjustedInterval time.Duration var adjustedInterval time.Duration
@@ -239,7 +255,7 @@ func (s *Service) parseQuery(queries []backend.DataQuery) (
end := queryModel.TimeRange.To end := queryModel.TimeRange.To
queryInterval := jsonModel.Get("interval").MustString("") queryInterval := jsonModel.Get("interval").MustString("")
dsInterval, err := tsdb.GetIntervalFrom(queryInterval, "", 0, 15*time.Second) foundInterval, err := tsdb.GetIntervalFrom(dsInfo.TimeInterval, queryInterval, 0, 15*time.Second)
hasQueryInterval := queryInterval != "" hasQueryInterval := queryInterval != ""
// Only use stepMode if we have interval in query, otherwise use "min" // Only use stepMode if we have interval in query, otherwise use "min"
if hasQueryInterval { if hasQueryInterval {
@@ -253,7 +269,7 @@ func (s *Service) parseQuery(queries []backend.DataQuery) (
return nil, err return nil, err
} }
calculatedInterval, err := s.intervalCalculator.Calculate(queries[0].TimeRange, dsInterval, tsdb.IntervalMode(intervalMode)) calculatedInterval, err := s.intervalCalculator.Calculate(queries[0].TimeRange, foundInterval, tsdb.IntervalMode(intervalMode))
if err != nil { if err != nil {
return nil, err return nil, err
} }
+22 -9
View File
@@ -56,7 +56,7 @@ func TestPrometheus(t *testing.T) {
To: now.Add(12 * time.Hour), To: now.Add(12 * time.Hour),
} }
query.TimeRange = timeRange query.TimeRange = timeRange
models, err := service.parseQuery([]backend.DataQuery{query}) models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Second*30, models[0].Step) require.Equal(t, time.Second*30, models[0].Step)
}) })
@@ -74,7 +74,7 @@ func TestPrometheus(t *testing.T) {
To: now.Add(12 * time.Hour), To: now.Add(12 * time.Hour),
} }
query.TimeRange = timeRange query.TimeRange = timeRange
models, err := service.parseQuery([]backend.DataQuery{query}) models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Second*7, models[0].Step) require.Equal(t, time.Second*7, models[0].Step)
}) })
@@ -92,7 +92,7 @@ func TestPrometheus(t *testing.T) {
To: now.Add(12 * time.Hour), To: now.Add(12 * time.Hour),
} }
query.TimeRange = timeRange query.TimeRange = timeRange
models, err := service.parseQuery([]backend.DataQuery{query}) models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Second*6, models[0].Step) require.Equal(t, time.Second*6, models[0].Step)
}) })
@@ -110,7 +110,7 @@ func TestPrometheus(t *testing.T) {
To: now.Add(12 * time.Hour), To: now.Add(12 * time.Hour),
} }
query.TimeRange = timeRange query.TimeRange = timeRange
models, err := service.parseQuery([]backend.DataQuery{query}) models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Second*30, models[0].Step) require.Equal(t, time.Second*30, models[0].Step)
}) })
@@ -128,7 +128,7 @@ func TestPrometheus(t *testing.T) {
To: now.Add(12 * time.Hour), To: now.Add(12 * time.Hour),
} }
query.TimeRange = timeRange query.TimeRange = timeRange
models, err := service.parseQuery([]backend.DataQuery{query}) models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Second*5, models[0].Step) require.Equal(t, time.Second*5, models[0].Step)
}) })
@@ -140,7 +140,7 @@ func TestPrometheus(t *testing.T) {
"intervalFactor": 1, "intervalFactor": 1,
"refId": "A" "refId": "A"
}`) }`)
models, err := service.parseQuery([]backend.DataQuery{query}) models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Minute*2, models[0].Step) require.Equal(t, time.Minute*2, models[0].Step)
@@ -149,7 +149,7 @@ func TestPrometheus(t *testing.T) {
To: now.Add(1 * time.Hour), To: now.Add(1 * time.Hour),
} }
query.TimeRange = timeRange query.TimeRange = timeRange
models, err = service.parseQuery([]backend.DataQuery{query}) models, err = service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Second*15, models[0].Step) require.Equal(t, time.Second*15, models[0].Step)
}) })
@@ -160,7 +160,7 @@ func TestPrometheus(t *testing.T) {
"format": "time_series", "format": "time_series",
"intervalFactor": 10, "intervalFactor": 10,
"refId": "A" "refId": "A"
}`)}) }`)}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Minute*20, models[0].Step) require.Equal(t, time.Minute*20, models[0].Step)
}) })
@@ -171,10 +171,23 @@ func TestPrometheus(t *testing.T) {
"format": "time_series", "format": "time_series",
"intervalFactor": 1, "intervalFactor": 1,
"refId": "A" "refId": "A"
}`)}) }`)}, &DatasourceInfo{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Minute*2, models[0].Step) require.Equal(t, time.Minute*2, models[0].Step)
}) })
t.Run("parsing query model specified scrape-interval in the data source", func(t *testing.T) {
models, err := service.parseQuery([]backend.DataQuery{queryContext(`{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"refId": "A"
}`)}, &DatasourceInfo{
TimeInterval: "240s",
})
require.NoError(t, err)
require.Equal(t, time.Minute*4, models[0].Step)
})
} }
func queryContext(json string) backend.DataQuery { func queryContext(json string) backend.DataQuery {
@@ -8,15 +8,15 @@ describe('PasswordField', () => {
placeholder: 'enter password', placeholder: 'enter password',
'data-testid': 'password-field', 'data-testid': 'password-field',
}; };
it('should renders correctly', () => { it('should render correctly', () => {
render(<PasswordField {...props} />); render(<PasswordField {...props} />);
expect(screen.getByTestId('password-field')).toBeInTheDocument(); expect(screen.getByTestId('password-field')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByRole('switch', { name: 'Show password' })).toBeInTheDocument();
}); });
it('should able to show password value if clicked on password-reveal icon', () => { it('should able to show password value if clicked on password-reveal icon', () => {
render(<PasswordField {...props} />); render(<PasswordField {...props} />);
expect(screen.getByTestId('password-field')).toHaveProperty('type', 'password'); expect(screen.getByTestId('password-field')).toHaveProperty('type', 'password');
fireEvent.click(screen.getByRole('button')); fireEvent.click(screen.getByRole('switch', { name: 'Show password' }));
expect(screen.getByTestId('password-field')).toHaveProperty('type', 'text'); expect(screen.getByTestId('password-field')).toHaveProperty('type', 'text');
}); });
}); });
@@ -29,7 +29,7 @@ export const PasswordField: FC<Props> = React.forwardRef<HTMLInputElement, Props
name={showPassword ? 'eye-slash' : 'eye'} name={showPassword ? 'eye-slash' : 'eye'}
type="button" type="button"
aria-controls={id} aria-controls={id}
aria-role="switch" role="switch"
aria-checked={showPassword} aria-checked={showPassword}
aria-label="Show password" aria-label="Show password"
onClick={() => { onClick={() => {
@@ -1,5 +1,5 @@
import { toDataFrame, ArrayVector, DataFrame, FieldType, toDataFrameDTO, DataFrameDTO } from '@grafana/data'; import { toDataFrame, ArrayVector, DataFrame, FieldType, toDataFrameDTO, DataFrameDTO } from '@grafana/data';
import { prepareTimeSeries, PrepareTimeSeriesOptions, timeSeriesFormat } from './prepareTimeSeries'; import { prepareTimeSeriesTransformer, PrepareTimeSeriesOptions, timeSeriesFormat } from './prepareTimeSeries';
describe('Prepair time series transformer', () => { describe('Prepair time series transformer', () => {
it('should transform wide to many', () => { it('should transform wide to many', () => {
@@ -19,7 +19,7 @@ describe('Prepair time series transformer', () => {
format: timeSeriesFormat.TimeSeriesMany, format: timeSeriesFormat.TimeSeriesMany,
}; };
expect(prepareTimeSeries(source, config)).toEqual([ expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([
toEquableDataFrame({ toEquableDataFrame({
name: 'wide', name: 'wide',
refId: 'A', refId: 'A',
@@ -59,7 +59,7 @@ describe('Prepair time series transformer', () => {
format: timeSeriesFormat.TimeSeriesMany, format: timeSeriesFormat.TimeSeriesMany,
}; };
expect(prepareTimeSeries(source, config)).toEqual([ expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([
toEquableDataFrame({ toEquableDataFrame({
name: 'wide', name: 'wide',
refId: 'A', refId: 'A',
@@ -107,7 +107,7 @@ describe('Prepair time series transformer', () => {
format: timeSeriesFormat.TimeSeriesMany, format: timeSeriesFormat.TimeSeriesMany,
}; };
expect(prepareTimeSeries(source, config)).toEqual([ expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([
toEquableDataFrame({ toEquableDataFrame({
name: 'wide', name: 'wide',
refId: 'A', refId: 'A',
@@ -162,7 +162,9 @@ describe('Prepair time series transformer', () => {
format: timeSeriesFormat.TimeSeriesMany, format: timeSeriesFormat.TimeSeriesMany,
}; };
expect(toEquableDataFrames(prepareTimeSeries(source, config))).toEqual(toEquableDataFrames(source)); expect(toEquableDataFrames(prepareTimeSeriesTransformer.transformer(config)(source))).toEqual(
toEquableDataFrames(source)
);
}); });
it('should return empty array when no timeseries exist', () => { it('should return empty array when no timeseries exist', () => {
@@ -191,7 +193,7 @@ describe('Prepair time series transformer', () => {
format: timeSeriesFormat.TimeSeriesMany, format: timeSeriesFormat.TimeSeriesMany,
}; };
expect(prepareTimeSeries(source, config)).toEqual([]); expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([]);
}); });
}); });
@@ -1,5 +1,5 @@
import { import {
DataTransformerInfo, SynchronousDataTransformerInfo,
DataFrame, DataFrame,
FieldType, FieldType,
DataTransformerID, DataTransformerID,
@@ -63,31 +63,30 @@ export function toTimeSeriesMany(data: DataFrame[]): DataFrame[] {
return result; return result;
} }
export function prepareTimeSeries(data: DataFrame[], options: PrepareTimeSeriesOptions): DataFrame[] { export const prepareTimeSeriesTransformer: SynchronousDataTransformerInfo<PrepareTimeSeriesOptions> = {
const format = options?.format ?? timeSeriesFormat.TimeSeriesWide;
if (format === timeSeriesFormat.TimeSeriesMany) {
return toTimeSeriesMany(data);
}
// Join by the first frame
const frame = outerJoinDataFrames({
frames: data,
joinBy: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
enforceSort: true,
keepOriginIndices: true,
});
return frame ? [frame] : [];
}
export const prepareTimeSeriesTransformer: DataTransformerInfo<PrepareTimeSeriesOptions> = {
id: DataTransformerID.prepareTimeSeries, id: DataTransformerID.prepareTimeSeries,
name: 'Prepare time series', name: 'Prepare time series',
description: `Will stretch data frames from the wide format into the long format. This is really helpful to be able to keep backwards compatability for panels not supporting the new wide format.`, description: `Will stretch data frames from the wide format into the long format. This is really helpful to be able to keep backwards compatability for panels not supporting the new wide format.`,
defaultOptions: {}, defaultOptions: {},
/** operator: (options) => (source) =>
* Return a modified copy of the series. If the transform is not or should not source.pipe(map((data) => prepareTimeSeriesTransformer.transformer(options)(data))),
* be applied, just return the input series
*/ transformer: (options: PrepareTimeSeriesOptions) => {
operator: (options) => (source) => source.pipe(map((data) => prepareTimeSeries(data, options))), const format = options?.format ?? timeSeriesFormat.TimeSeriesWide;
if (format === timeSeriesFormat.TimeSeriesMany) {
return toTimeSeriesMany;
}
return (data: DataFrame[]) => {
// Join by the first frame
const frame = outerJoinDataFrames({
frames: data,
joinBy: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
enforceSort: true,
keepOriginIndices: true,
});
return frame ? [frame] : [];
};
},
}; };
@@ -3,7 +3,7 @@ import { render, waitFor } from '@testing-library/react';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { RuleList } from './RuleList'; import { RuleList } from './RuleList';
import { byTestId, byText } from 'testing-library-selector'; import { byRole, byTestId, byText } from 'testing-library-selector';
import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
import { getAllDataSources } from './utils/config'; import { getAllDataSources } from './utils/config';
import { fetchRules } from './api/prometheus'; import { fetchRules } from './api/prometheus';
@@ -77,6 +77,7 @@ const ui = {
ruleRow: byTestId('row'), ruleRow: byTestId('row'),
expandedContent: byTestId('expanded-content'), expandedContent: byTestId('expanded-content'),
rulesFilterInput: byTestId('search-query-input'), rulesFilterInput: byTestId('search-query-input'),
moreErrorsButton: byRole('button', { name: /more errors/ }),
}; };
describe('RuleList', () => { describe('RuleList', () => {
@@ -164,6 +165,10 @@ describe('RuleList', () => {
const errors = await ui.cloudRulesSourceErrors.find(); const errors = await ui.cloudRulesSourceErrors.find();
expect(errors).not.toHaveTextContent(
'Failed to load rules state from Prometheus-broken: this datasource is broken'
);
userEvent.click(ui.moreErrorsButton.get());
expect(errors).toHaveTextContent('Failed to load rules state from Prometheus-broken: this datasource is broken'); expect(errors).toHaveTextContent('Failed to load rules state from Prometheus-broken: this datasource is broken');
}); });

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