Merge branch 'main' into ifrost/graphite-on-react-6
This commit is contained in:
@@ -91,7 +91,7 @@ shellcheck: $(SH_FILES) ## Run checks for shell scripts.
|
||||
build-docker-dev: ## Build Docker image for development (fast).
|
||||
@echo "build development container"
|
||||
@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
|
||||
cd packaging/docker && docker build --tag grafana/grafana:dev .
|
||||
|
||||
@@ -141,7 +141,7 @@ clean: ## Clean up intermediate build artifacts.
|
||||
rm -rf public/build
|
||||
|
||||
# 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.
|
||||
drone:
|
||||
drone starlark
|
||||
|
||||
@@ -715,6 +715,11 @@ global_session = -1
|
||||
# global limit of alerts
|
||||
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]
|
||||
# Disable alerting engine & UI features
|
||||
|
||||
@@ -698,6 +698,11 @@
|
||||
# global limit of alerts
|
||||
;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]
|
||||
# Disable alerting engine & UI features
|
||||
|
||||
@@ -50,6 +50,10 @@ The following guidelines are specific to Grafana documentation. For the most par
|
||||
|
||||
### 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:
|
||||
- Active: Grafana displays the heatmap visualization.
|
||||
- Passive: The heatmap visualization is displayed.
|
||||
|
||||
+104
-1
@@ -73,7 +73,7 @@ Family: scuemata.#Family & {
|
||||
schemaVersion: number | *30
|
||||
// Version of the dashboard, incremented each time the dashboard is updated.
|
||||
version?: number
|
||||
panels?: [...#Panel]
|
||||
panels?: [...(#Panel | #GraphPanel | #RowPanel)]
|
||||
|
||||
// TODO docs
|
||||
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(targetType="enum")
|
||||
@@ -191,6 +191,12 @@ Family: scuemata.#Family & {
|
||||
// TODO docs
|
||||
maxDataPoints?: number
|
||||
|
||||
// TODO docs
|
||||
thresholds?: [...]
|
||||
|
||||
// TODO docs
|
||||
timeRegions?: [...]
|
||||
|
||||
// TODO docs
|
||||
// TODO tighter constraint
|
||||
interval?: string
|
||||
@@ -205,6 +211,7 @@ Family: scuemata.#Family & {
|
||||
|
||||
// The values depend on panel type
|
||||
options: {...}
|
||||
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
// 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
@@ -40,7 +40,7 @@ LineConfig: {
|
||||
lineWidth?: number
|
||||
lineInterpolation?: LineInterpolation
|
||||
lineStyle?: LineStyle
|
||||
spanNulls?: bool
|
||||
spanNulls?: bool | number
|
||||
} @cuetsy(targetType="interface")
|
||||
FillConfig: {
|
||||
fillColor?: string
|
||||
@@ -86,6 +86,8 @@ GraphFieldConfig: LineConfig & FillConfig & PointsConfig & AxisConfig & {
|
||||
VizLegendOptions: {
|
||||
displayMode: LegendDisplayMode
|
||||
placement: LegendPlacement
|
||||
asTable: bool | *false
|
||||
isVisible: bool | *false
|
||||
calcs: [...string]
|
||||
} @cuetsy(targetType="interface")
|
||||
VizTooltipOptions: {
|
||||
|
||||
@@ -10485,7 +10485,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -10613,7 +10613,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
|
||||
@@ -259,10 +259,9 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
"tooltip": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
@@ -549,7 +548,7 @@
|
||||
"fillOpacity": 74,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -665,7 +664,7 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -806,7 +805,7 @@
|
||||
"fillOpacity": 50,
|
||||
"gradientMode": "hue",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -924,7 +923,7 @@
|
||||
"fillOpacity": 40,
|
||||
"gradientMode": "hue",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -1042,7 +1041,7 @@
|
||||
"fillOpacity": 40,
|
||||
"gradientMode": "hue",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -1183,12 +1182,9 @@
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": {
|
||||
"label": "None",
|
||||
"value": "none"
|
||||
},
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -1289,12 +1285,9 @@
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": {
|
||||
"label": "None",
|
||||
"value": "none"
|
||||
},
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -1399,12 +1392,9 @@
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": {
|
||||
"label": "None",
|
||||
"value": "none"
|
||||
},
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -1533,7 +1523,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "opacity",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -1768,12 +1758,9 @@
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": {
|
||||
"label": "None",
|
||||
"value": "none"
|
||||
},
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -2093,12 +2080,9 @@
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": {
|
||||
"label": "None",
|
||||
"value": "none"
|
||||
},
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -2420,7 +2404,7 @@
|
||||
"fillOpacity": 82,
|
||||
"gradientMode": "hue",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
@@ -2566,7 +2550,7 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "hue",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -106,7 +105,6 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -177,7 +175,6 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -247,7 +244,6 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -318,7 +314,6 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -388,7 +383,6 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -460,7 +454,6 @@
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -149,7 +149,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -265,7 +265,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -400,7 +400,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -507,7 +507,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -623,7 +623,7 @@
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -758,7 +758,7 @@
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -902,7 +902,7 @@
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
@@ -1045,7 +1045,7 @@
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"graph": false,
|
||||
"viz": false,
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
|
||||
@@ -49,10 +49,6 @@
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "line"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
influxdb1:
|
||||
image: influxdb:1.8.6
|
||||
image: influxdb:1.8
|
||||
container_name: influxdb1
|
||||
ports:
|
||||
- '2004:2004'
|
||||
|
||||
@@ -1099,6 +1099,16 @@ Sets a global limit on number of alert rules that can be created. Default is -1
|
||||
|
||||
<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]
|
||||
|
||||
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:** Grafana uses the `Events API V2` integration. This can be configured for each service.
|
||||
|
||||
### VictorOps
|
||||
|
||||
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({
|
||||
data: [frame],
|
||||
key: query.refId,
|
||||
state: LoadingState.Streaming,
|
||||
});
|
||||
}, 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-`.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ weight = 200
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -24,7 +24,7 @@ For example, if you have server names or region names that never change, then yo
|
||||
|
||||
## 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. 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.
|
||||
|
||||
@@ -52,7 +52,7 @@ require (
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/gosimple/slug v1.9.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/grpc-ecosystem/go-grpc-middleware v1.3.0
|
||||
github.com/hashicorp/go-hclog v0.16.1
|
||||
@@ -67,6 +67,7 @@ require (
|
||||
github.com/laher/mergefs v0.1.1
|
||||
github.com/lib/pq v1.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/mattn/go-isatty v0.0.12
|
||||
github.com/mattn/go-sqlite3 v1.14.7
|
||||
|
||||
@@ -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.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.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/go.mod h1:GHIsn+EohCChsdu5YouNZewqLeV9L2FNw4DEJU3P9qE=
|
||||
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/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/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.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls=
|
||||
|
||||
+5
-5
@@ -183,7 +183,7 @@
|
||||
"mutationobserver-shim": "0.3.3",
|
||||
"ngtemplate-loader": "2.0.1",
|
||||
"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-loader": "3.0.0",
|
||||
"postcss-reporter": "6.0.1",
|
||||
@@ -221,6 +221,9 @@
|
||||
"@emotion/react": "11.1.5",
|
||||
"@grafana/aws-sdk": "0.0.3",
|
||||
"@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",
|
||||
"@reduxjs/toolkit": "1.5.0",
|
||||
"@sentry/browser": "5.25.0",
|
||||
@@ -265,12 +268,9 @@
|
||||
"mousetrap": "1.6.5",
|
||||
"mousetrap-global-bind": "1.1.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",
|
||||
"pluralize": "^8.0.0",
|
||||
"prismjs": "1.23.0",
|
||||
"prismjs": "1.24.0",
|
||||
"prop-types": "15.7.2",
|
||||
"rc-cascader": "1.0.1",
|
||||
"re-resizable": "^6.2.0",
|
||||
|
||||
@@ -255,6 +255,10 @@ export function emphasize(color: string, coefficient = 0.15) {
|
||||
* @beta
|
||||
*/
|
||||
export function alpha(color: string, value: number) {
|
||||
if (color === '') {
|
||||
return '#000000';
|
||||
}
|
||||
|
||||
value = clamp(value);
|
||||
|
||||
// hex 6, hex 8 (w/alpha)
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { seriesToColumnsTransformer } from './seriesToColumns';
|
||||
import { DataFrame } from '../../types/dataFrame';
|
||||
import { getTimeField } from '../../dataframe/processDataFrame';
|
||||
import { DataTransformerInfo } from '../../types/transformations';
|
||||
import { SynchronousDataTransformerInfo } from '../../types/transformations';
|
||||
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,
|
||||
name: 'Ensure Columns Transformer',
|
||||
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) {
|
||||
return of(data).pipe(
|
||||
seriesToColumnsTransformer.operator({
|
||||
byField: timeFieldName,
|
||||
})
|
||||
);
|
||||
}
|
||||
operator: (options) => (source) => source.pipe(map((data) => ensureColumnsTransformer.transformer(options)(data))),
|
||||
|
||||
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 { DataTransformerID } from './ids';
|
||||
@@ -73,7 +73,7 @@ export const histogramFieldInfo = {
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const histogramTransformer: DataTransformerInfo<HistogramTransformerOptions> = {
|
||||
export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTransformerOptions> = {
|
||||
id: DataTransformerID.histogram,
|
||||
name: 'Histogram',
|
||||
description: 'Calculate a histogram from input data',
|
||||
@@ -81,23 +81,18 @@ export const histogramTransformer: DataTransformerInfo<HistogramTransformerOptio
|
||||
fields: {},
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a modified copy of the series. If the transform is not or should not
|
||||
* be applied, just return the input series
|
||||
*/
|
||||
operator: (options) => (source) =>
|
||||
source.pipe(
|
||||
map((data) => {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return data;
|
||||
}
|
||||
const hist = buildHistogram(data, options);
|
||||
if (hist == null) {
|
||||
return [];
|
||||
}
|
||||
return [histogramFieldsToFrame(hist)];
|
||||
})
|
||||
),
|
||||
operator: (options) => (source) => source.pipe(map((data) => histogramTransformer.transformer(options)(data))),
|
||||
|
||||
transformer: (options: HistogramTransformerOptions) => (data: DataFrame[]) => {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return data;
|
||||
}
|
||||
const hist = buildHistogram(data, options);
|
||||
if (hist == null) {
|
||||
return [];
|
||||
}
|
||||
return [histogramFieldsToFrame(hist)];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { DataTransformerInfo, FieldMatcher } from '../../types';
|
||||
import { DataFrame, SynchronousDataTransformerInfo, FieldMatcher } from '../../types';
|
||||
import { DataTransformerID } from './ids';
|
||||
import { outerJoinDataFrames } from './joinDataFrames';
|
||||
import { fieldMatchers } from '../matchers';
|
||||
@@ -10,27 +10,29 @@ export interface SeriesToColumnsOptions {
|
||||
byField?: string; // empty will pick the field automatically
|
||||
}
|
||||
|
||||
export const seriesToColumnsTransformer: DataTransformerInfo<SeriesToColumnsOptions> = {
|
||||
export const seriesToColumnsTransformer: SynchronousDataTransformerInfo<SeriesToColumnsOptions> = {
|
||||
id: DataTransformerID.seriesToColumns,
|
||||
name: 'Series as columns', // Called 'Outer join' in the UI!
|
||||
description: 'Groups series by field and returns values as columns',
|
||||
defaultOptions: {
|
||||
byField: undefined, // DEFAULT_KEY_FIELD,
|
||||
},
|
||||
operator: (options) => (source) =>
|
||||
source.pipe(
|
||||
map((data) => {
|
||||
if (data.length > 1) {
|
||||
let joinBy: FieldMatcher | undefined = undefined;
|
||||
if (options.byField) {
|
||||
joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField);
|
||||
}
|
||||
const joined = outerJoinDataFrames({ frames: data, joinBy });
|
||||
if (joined) {
|
||||
return [joined];
|
||||
}
|
||||
|
||||
operator: (options) => (source) => source.pipe(map((data) => seriesToColumnsTransformer.transformer(options)(data))),
|
||||
|
||||
transformer: (options: SeriesToColumnsOptions) => {
|
||||
let joinBy: FieldMatcher | undefined = undefined;
|
||||
return (data: DataFrame[]) => {
|
||||
if (data.length > 1) {
|
||||
if (options.byField && !joinBy) {
|
||||
joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField);
|
||||
}
|
||||
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)
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface DataTransformerInfo<TOptions = any> extends RegistryItemWithOptions {
|
||||
/**
|
||||
@@ -14,6 +16,19 @@ export interface DataTransformerInfo<TOptions = any> extends RegistryItemWithOpt
|
||||
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> {
|
||||
/**
|
||||
* Unique identifier of transformer
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Components = {
|
||||
},
|
||||
Panels: {
|
||||
Panel: {
|
||||
title: (title: string) => `Panel header ${title}`,
|
||||
title: (title: string) => `data-testid Panel header ${title}`,
|
||||
headerItems: (item: string) => `Panel header item ${item}`,
|
||||
containerByTitle: (title: string) => `${title} panel`,
|
||||
headerCornerInfo: (mode: string) => `Panel header ${mode}`,
|
||||
|
||||
@@ -57,17 +57,19 @@ export const bundlePlugin = async ({ watch, production, preserveConsole }: Plugi
|
||||
} else {
|
||||
compiler.run((err: Error, stats: webpack.Stats) => {
|
||||
if (err) {
|
||||
reject(err.message);
|
||||
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.hasErrors()) {
|
||||
stats.compilation.errors.forEach((e) => {
|
||||
console.log(e.message);
|
||||
});
|
||||
|
||||
reject('Build failed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n', stats.toString({ colors: true }), '\n');
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@ export const useSpinner = async (label: string, fn: () => Promise<any>, killProc
|
||||
|
||||
if (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
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"react-transition-group": "4.4.1",
|
||||
"slate": "0.47.8",
|
||||
"tinycolor2": "1.4.1",
|
||||
"uplot": "1.6.14"
|
||||
"uplot": "1.6.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "16.0.0",
|
||||
|
||||
@@ -59,6 +59,7 @@ export class MultiSelectValueEditor<T> extends React.PureComponent<Props<T>, Sta
|
||||
const { settings } = item;
|
||||
return (
|
||||
<MultiSelect<T>
|
||||
menuShouldPortal
|
||||
isLoading={isLoading}
|
||||
value={value}
|
||||
defaultValue={value}
|
||||
|
||||
@@ -62,6 +62,7 @@ export class SelectValueEditor<T> extends React.PureComponent<Props<T>, State<T>
|
||||
}
|
||||
return (
|
||||
<Select<T>
|
||||
menuShouldPortal
|
||||
isLoading={isLoading}
|
||||
value={current}
|
||||
defaultValue={value}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const PageToolbar: FC<Props> = React.memo(
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<nav className={styles.navElement}>
|
||||
<nav aria-label="Search links" className={styles.navElement}>
|
||||
{parent && parentHref && (
|
||||
<>
|
||||
<Link
|
||||
|
||||
@@ -6,7 +6,10 @@ LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(targetType="enum")
|
||||
VizLegendOptions: {
|
||||
displayMode: LegendDisplayMode
|
||||
placement: LegendPlacement
|
||||
asTable: bool | *false
|
||||
isVisible: bool | *false
|
||||
calcs: [...string]
|
||||
|
||||
} @cuetsy(targetType="interface")
|
||||
|
||||
// TODO this excludes all the types that include function definitions
|
||||
@@ -436,7 +436,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -25,10 +25,6 @@ const cursorDefaults: Cursor = {
|
||||
size: (u, seriesIdx) => u.series[seriesIdx].points.size * 2,
|
||||
/*@ts-ignore*/
|
||||
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: {
|
||||
prox: 30,
|
||||
@@ -50,6 +46,7 @@ export class UPlotConfigBuilder {
|
||||
private hooks: Hooks.Arrays = {};
|
||||
private tz: string | undefined = undefined;
|
||||
private sync = false;
|
||||
private frame: DataFrame | undefined = undefined;
|
||||
// to prevent more than one threshold per scale
|
||||
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
||||
/**
|
||||
@@ -158,7 +155,10 @@ export class UPlotConfigBuilder {
|
||||
}
|
||||
|
||||
setPrepData(prepData: PrepData) {
|
||||
this.prepData = prepData;
|
||||
this.prepData = (frame) => {
|
||||
this.frame = frame;
|
||||
return prepData(frame);
|
||||
};
|
||||
}
|
||||
|
||||
setSync() {
|
||||
@@ -187,7 +187,25 @@ export class UPlotConfigBuilder {
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { DataFrameFieldIndex, FALLBACK_COLOR, FieldColorMode, GrafanaTheme2, ThresholdsConfig } from '@grafana/data';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import {
|
||||
colorManipulator,
|
||||
DataFrameFieldIndex,
|
||||
FALLBACK_COLOR,
|
||||
FieldColorMode,
|
||||
GrafanaTheme2,
|
||||
ThresholdsConfig,
|
||||
} from '@grafana/data';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
import {
|
||||
BarAlignment,
|
||||
@@ -55,16 +61,18 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
} = this.props;
|
||||
|
||||
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) {
|
||||
lineConfig.paths = pathBuilder;
|
||||
lineConfig.stroke = lineColor;
|
||||
lineConfig.width = lineWidth;
|
||||
} else if (drawStyle === DrawStyle.Points) {
|
||||
lineConfig.paths = () => null;
|
||||
} else if (drawStyle != null) {
|
||||
lineConfig.stroke = lineColor;
|
||||
lineConfig.width = lineWidth;
|
||||
if (lineStyle && lineStyle.fill !== 'solid') {
|
||||
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> = {
|
||||
points: {
|
||||
stroke: lineColor,
|
||||
fill: lineColor,
|
||||
stroke: useColor,
|
||||
fill: useColor,
|
||||
size: pointSize,
|
||||
filter: pointsFilter,
|
||||
},
|
||||
@@ -153,7 +165,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
return getScaleGradientFn(opacityPercent, theme, colorMode, thresholds);
|
||||
default:
|
||||
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 { GraphThresholdsStyleConfig, GraphTresholdsStyleMode } from '../config';
|
||||
import { getDataRange, GradientDirection, scaleGradient } from './gradientFills';
|
||||
|
||||
export interface UPlotThresholdOptions {
|
||||
scaleKey: string;
|
||||
@@ -13,7 +14,6 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
|
||||
return (u: uPlot) => {
|
||||
const ctx = u.ctx;
|
||||
const { scaleKey, thresholds, theme, config } = options;
|
||||
const { steps } = thresholds;
|
||||
const { min: xMin, max: xMax } = u.scales.x;
|
||||
const { min: yMin, max: yMax } = u.scales[scaleKey];
|
||||
|
||||
@@ -21,6 +21,18 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
|
||||
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() {
|
||||
// Thresholds below a transparent threshold is treated like "less than", and line drawn previous threshold
|
||||
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 (transparentIndex >= idx && idx > 0) {
|
||||
color = tinycolor(getColorForTheme(steps[idx - 1].color, theme.v1));
|
||||
color = tinycolor(theme.visualization.getColorByName(steps[idx - 1].color));
|
||||
} else {
|
||||
color = tinycolor(getColorForTheme(step.color, theme.v1));
|
||||
color = tinycolor(theme.visualization.getColorByName(step.color));
|
||||
}
|
||||
|
||||
// Unless alpha specififed set to default value
|
||||
@@ -50,10 +62,10 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
|
||||
color.setAlpha(0.7);
|
||||
}
|
||||
|
||||
let x0 = u.valToPos(xMin!, 'x', true);
|
||||
let y0 = u.valToPos(step.value, scaleKey, true);
|
||||
let x1 = u.valToPos(xMax!, 'x', true);
|
||||
let y1 = u.valToPos(step.value, scaleKey, true);
|
||||
let x0 = Math.round(u.valToPos(xMin!, 'x', true));
|
||||
let y0 = Math.round(u.valToPos(step.value, scaleKey, true));
|
||||
let x1 = Math.round(u.valToPos(xMax!, 'x', true));
|
||||
let y1 = Math.round(u.valToPos(step.value, scaleKey, true));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
@@ -66,49 +78,26 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
|
||||
}
|
||||
|
||||
function addAreas() {
|
||||
for (let idx = 0; idx < steps.length; idx++) {
|
||||
const step = steps[idx];
|
||||
let grd = scaleGradient(
|
||||
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 (step.value > yMax!) {
|
||||
continue;
|
||||
}
|
||||
if (color.getAlpha() === 1) {
|
||||
color.setAlpha(0.15);
|
||||
}
|
||||
|
||||
// if this is the last step make the next step the same color but +Infinity
|
||||
const nextStep =
|
||||
idx + 1 < steps.length
|
||||
? steps[idx + 1]
|
||||
: {
|
||||
...step,
|
||||
value: Infinity,
|
||||
};
|
||||
return [step.value, color.toString()];
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
let color = tinycolor(getColorForTheme(step.color, theme.v1));
|
||||
|
||||
// Ignore fully transparent colors
|
||||
const alpha = color.getAlpha();
|
||||
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();
|
||||
}
|
||||
ctx.save();
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
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 uPlot from 'uplot';
|
||||
import { getCanvasContext } from '../../../utils/measureText';
|
||||
@@ -11,8 +18,8 @@ export function getOpacityGradientFn(
|
||||
const ctx = getCanvasContext();
|
||||
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(1, tinycolor(color).setAlpha(0).toRgbString());
|
||||
gradient.addColorStop(0, colorManipulator.alpha(color, opacity));
|
||||
gradient.addColorStop(1, colorManipulator.alpha(color, 0));
|
||||
|
||||
return gradient;
|
||||
};
|
||||
@@ -39,10 +46,132 @@ export function getHueGradientFn(
|
||||
return gradient;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Experimental & quick and dirty test
|
||||
* Not being used
|
||||
*/
|
||||
|
||||
export enum GradientDirection {
|
||||
'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(
|
||||
opacity: number,
|
||||
theme: GrafanaTheme2,
|
||||
@@ -58,68 +187,41 @@ export function getScaleGradientFn(
|
||||
}
|
||||
|
||||
return (plot: uPlot, seriesIdx: number) => {
|
||||
// A uplot bug (I think) where this is called before there is bbox
|
||||
// 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;
|
||||
}
|
||||
let scaleKey = plot.series[seriesIdx].scale!;
|
||||
|
||||
const ctx = getCanvasContext();
|
||||
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);
|
||||
};
|
||||
let gradient: CanvasGradient | string = '';
|
||||
|
||||
if (colorMode.id === FieldColorModeId.Thresholds) {
|
||||
for (let idx = 0; idx < thresholds.steps.length; idx++) {
|
||||
const step = thresholds.steps[idx];
|
||||
|
||||
if (thresholds.mode === ThresholdsMode.Absolute) {
|
||||
const value = step.value === -Infinity ? scaleMin : step.value;
|
||||
addColorStop(value, step.color);
|
||||
|
||||
if (thresholds.steps.length > idx + 1) {
|
||||
// to make the gradient discrete
|
||||
addColorStop(thresholds.steps[idx + 1].value - 0.00000001, step.color);
|
||||
}
|
||||
} else {
|
||||
const percent = step.value === -Infinity ? 0 : step.value;
|
||||
const realValue = (percent / 100) * scaleRange;
|
||||
addColorStop(realValue, step.color);
|
||||
|
||||
// to make the gradient discrete
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (thresholds.mode === ThresholdsMode.Absolute) {
|
||||
const valueStops = thresholds.steps.map(
|
||||
(step) =>
|
||||
[step.value, colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity)] as ValueStop
|
||||
);
|
||||
gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true);
|
||||
} else {
|
||||
const [min, max] = getDataRange(plot, scaleKey);
|
||||
const range = max - min;
|
||||
const valueStops = thresholds.steps.map(
|
||||
(step) =>
|
||||
[
|
||||
min + range * (step.value / 100),
|
||||
colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity),
|
||||
] as ValueStop
|
||||
);
|
||||
gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true);
|
||||
}
|
||||
} else if (colorMode.getColors) {
|
||||
const colors = colorMode.getColors(theme);
|
||||
const stepValue = (scaleMax - scaleMin) / colors.length;
|
||||
|
||||
for (let idx = 0; idx < colors.length; idx++) {
|
||||
addColorStop(scaleMin + stepValue * idx, colors[idx]);
|
||||
}
|
||||
const [min, max] = getDataRange(plot, scaleKey);
|
||||
const range = max - min;
|
||||
const valueStops = colors.map(
|
||||
(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;
|
||||
|
||||
@@ -17,7 +17,7 @@ LineConfig: {
|
||||
lineWidth?: number
|
||||
lineInterpolation?: LineInterpolation
|
||||
lineStyle?: LineStyle
|
||||
spanNulls?: bool
|
||||
spanNulls?: bool | number
|
||||
} @cuetsy(targetType="interface")
|
||||
|
||||
FillConfig: {
|
||||
|
||||
@@ -29,15 +29,6 @@ interface TooltipPluginProps {
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -123,16 +114,13 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
|
||||
})(u);
|
||||
});
|
||||
} else {
|
||||
let prevIdx: number | null = null;
|
||||
let prevIdxs: Array<number | null> | null = null;
|
||||
config.addHook('setLegend', (u) => {
|
||||
setFocusedPointIdx(u.legend.idx!);
|
||||
setFocusedPointIdxs(u.legend.idxs!.slice());
|
||||
});
|
||||
|
||||
// default series/datapoint idx retireval
|
||||
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();
|
||||
if (!bbox) {
|
||||
return;
|
||||
|
||||
@@ -438,6 +438,7 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
|
||||
)}
|
||||
</span>
|
||||
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
|
||||
<small className={styles.endpointName}> | {label}</small>
|
||||
</a>
|
||||
{createSpanLink &&
|
||||
(() => {
|
||||
|
||||
@@ -477,7 +477,7 @@ func (hs *HTTPServer) CheckDatasourceHealth(c *models.ReqContext) response.Respo
|
||||
}
|
||||
|
||||
if resp.Status != backend.HealthStatusOk {
|
||||
return response.JSON(503, payload)
|
||||
return response.JSON(400, payload)
|
||||
}
|
||||
|
||||
return response.JSON(200, payload)
|
||||
|
||||
@@ -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.
|
||||
type Command struct {
|
||||
Client utils.ApiClient
|
||||
@@ -138,7 +144,7 @@ var cueCommands = []*cli.Command{
|
||||
{
|
||||
Name: "validate-schema",
|
||||
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
|
||||
to basic standards - valid CUE, valid scuemata, etc. Note that this
|
||||
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",
|
||||
Usage: "validate resource files (e.g. dashboard JSON) against schema",
|
||||
Action: runPluginCommand(cmd.validateResources),
|
||||
Action: runCueCommand(cmd.validateResources),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "dashboard",
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
_ "github.com/grpc-ecosystem/go-grpc-middleware"
|
||||
_ "github.com/jung-kurt/gofpdf"
|
||||
_ "github.com/linkedin/goavro/v2"
|
||||
_ "github.com/m3db/prometheus_remote_client_golang/promremote"
|
||||
_ "github.com/pkg/errors"
|
||||
_ "github.com/robfig/cron"
|
||||
_ "github.com/robfig/cron/v3"
|
||||
|
||||
@@ -58,7 +58,6 @@ func Gziper() func(http.Handler) http.Handler {
|
||||
|
||||
for _, pathMatcher := range gzipIgnoredPaths {
|
||||
if pathMatcher(requestPath) {
|
||||
fmt.Println("skip path", requestPath)
|
||||
next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,7 +299,6 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"channel": sn.recipient,
|
||||
"text": evalContext.GetNotificationTitle(),
|
||||
"attachments": []map[string]interface{}{
|
||||
attachment,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
@@ -20,10 +21,17 @@ import (
|
||||
// timeNow makes it possible to test usage of time
|
||||
var timeNow = time.Now
|
||||
|
||||
type Scheduler interface {
|
||||
AlertmanagersFor(orgID int64) []*url.URL
|
||||
DroppedAlertmanagersFor(orgID int64) []*url.URL
|
||||
}
|
||||
|
||||
type Alertmanager interface {
|
||||
// Configuration
|
||||
SaveAndApplyConfig(config *apimodels.PostableUserConfig) error
|
||||
SaveAndApplyDefaultConfig() error
|
||||
// temporary add orgID parameter; this will move to the Alertmanager wrapper when it will be available
|
||||
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
|
||||
|
||||
// Silences
|
||||
@@ -61,19 +69,19 @@ func (api *API) RegisterAPIEndpoints(m *metrics.Metrics) {
|
||||
DataProxy: api.DataProxy,
|
||||
}
|
||||
|
||||
// Register endpoints for proxing to Alertmanager-compatible backends.
|
||||
// Register endpoints for proxying to Alertmanager-compatible backends.
|
||||
api.RegisterAlertmanagerApiEndpoints(NewForkedAM(
|
||||
api.DatasourceCache,
|
||||
NewLotexAM(proxy, logger),
|
||||
AlertmanagerSrv{store: api.AlertingStore, am: api.Alertmanager, log: logger},
|
||||
), m)
|
||||
// Register endpoints for proxing to Prometheus-compatible backends.
|
||||
// Register endpoints for proxying to Prometheus-compatible backends.
|
||||
api.RegisterPrometheusApiEndpoints(NewForkedProm(
|
||||
api.DatasourceCache,
|
||||
NewLotexProm(proxy, logger),
|
||||
PrometheusSrv{log: logger, manager: api.StateManager, store: api.RuleStore},
|
||||
), m)
|
||||
// Register endpoints for proxing to Cortex Ruler-compatible backends.
|
||||
// Register endpoints for proxying to Cortex Ruler-compatible backends.
|
||||
api.RegisterRulerApiEndpoints(NewForkedRuler(
|
||||
api.DatasourceCache,
|
||||
NewLotexRuler(proxy, logger),
|
||||
@@ -87,7 +95,8 @@ func (api *API) RegisterAPIEndpoints(m *metrics.Metrics) {
|
||||
log: logger,
|
||||
}, m)
|
||||
api.RegisterConfigurationApiEndpoints(AdminSrv{
|
||||
store: api.AdminConfigStore,
|
||||
log: logger,
|
||||
store: api.AdminConfigStore,
|
||||
log: logger,
|
||||
scheduler: api.Schedule,
|
||||
}, m)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,32 @@ import (
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"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 {
|
||||
store store.AdminConfigurationStore
|
||||
log log.Logger
|
||||
scheduler Scheduler
|
||||
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 {
|
||||
@@ -56,7 +77,7 @@ func (srv AdminSrv) RoutePostNGalertConfig(c *models.ReqContext, body apimodels.
|
||||
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 {
|
||||
@@ -70,5 +91,5 @@ func (srv AdminSrv) RouteDeleteNGalertConfig(c *models.ReqContext) response.Resp
|
||||
return ErrResp(http.StatusInternalServerError, err, "")
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, "admin configuration deleted")
|
||||
return response.JSON(http.StatusOK, util.DynMap{"message": "admin configuration deleted"})
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *models.ReqContext) respo
|
||||
if !c.HasUserRole(models.ROLE_EDITOR) {
|
||||
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)
|
||||
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) {
|
||||
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 errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
|
||||
return ErrResp(http.StatusNotFound, err, "")
|
||||
@@ -201,7 +202,7 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap
|
||||
}
|
||||
|
||||
// Get the last known working configuration
|
||||
query := ngmodels.GetLatestAlertmanagerConfigurationQuery{}
|
||||
query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: c.OrgId}
|
||||
if err := srv.store.GetLatestAlertmanagerConfiguration(&query); err != nil {
|
||||
// If we don't have a configuration there's nothing for us to know and we should just continue saving the new one
|
||||
if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
|
||||
@@ -255,7 +256,7 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap
|
||||
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)
|
||||
return ErrResp(http.StatusBadRequest, err, "failed to save and apply Alertmanager configuration")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
type ConfigurationApiService interface {
|
||||
RouteDeleteNGalertConfig(*models.ReqContext) response.Response
|
||||
RouteGetAlertmanagers(*models.ReqContext) response.Response
|
||||
RouteGetNGalertConfig(*models.ReqContext) response.Response
|
||||
RoutePostNGalertConfig(*models.ReqContext, apimodels.PostableNGalertConfig) response.Response
|
||||
}
|
||||
@@ -36,6 +37,15 @@ func (api *API) RegisterConfigurationApiEndpoints(srv ConfigurationApiService, m
|
||||
m,
|
||||
),
|
||||
)
|
||||
group.Get(
|
||||
toMacaronPath("/api/v1/ngalert/alertmanagers"),
|
||||
metrics.Instrument(
|
||||
http.MethodGet,
|
||||
"/api/v1/ngalert/alertmanagers",
|
||||
srv.RouteGetAlertmanagers,
|
||||
m,
|
||||
),
|
||||
)
|
||||
group.Get(
|
||||
toMacaronPath("/api/v1/ngalert/admin_config"),
|
||||
metrics.Instrument(
|
||||
|
||||
@@ -16,6 +16,12 @@ spec.json: $(GO_PKG_FILES)
|
||||
quay.io/goswagger/swagger:$(SWAGGER_TAG) \
|
||||
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
|
||||
go run cmd/clean-swagger/main.go -if $(<) -of $@
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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
|
||||
//
|
||||
// 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 {
|
||||
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
|
||||
//
|
||||
// Responses:
|
||||
// 200: GettableAlerts
|
||||
// 200: gettableAlerts
|
||||
// 400: ValidationError
|
||||
|
||||
// swagger:route POST /api/alertmanager/{Recipient}/api/v2/alerts alertmanager RoutePostAMAlerts
|
||||
@@ -70,7 +70,7 @@ import (
|
||||
// get alertmanager alerts
|
||||
//
|
||||
// Responses:
|
||||
// 200: AlertGroups
|
||||
// 200: alertGroups
|
||||
// 400: ValidationError
|
||||
|
||||
// swagger:route GET /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteGetSilences
|
||||
@@ -78,7 +78,7 @@ import (
|
||||
// get silences
|
||||
//
|
||||
// Responses:
|
||||
// 200: GettableSilences
|
||||
// 200: gettableSilences
|
||||
// 400: ValidationError
|
||||
|
||||
// swagger:route POST /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteCreateSilence
|
||||
@@ -86,7 +86,7 @@ import (
|
||||
// create silence
|
||||
//
|
||||
// Responses:
|
||||
// 201: GettableSilence
|
||||
// 201: gettableSilence
|
||||
// 400: ValidationError
|
||||
|
||||
// swagger:route GET /api/alertmanager/{Recipient}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence
|
||||
@@ -94,7 +94,7 @@ import (
|
||||
// get silence
|
||||
//
|
||||
// Responses:
|
||||
// 200: GettableSilence
|
||||
// 200: gettableSilence
|
||||
// 400: ValidationError
|
||||
|
||||
// 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
|
||||
|
||||
// swagger:model
|
||||
// swagger:model gettableSilences
|
||||
type GettableSilences = amv2.GettableSilences
|
||||
|
||||
// swagger:model
|
||||
// swagger:model gettableSilence
|
||||
type GettableSilence = amv2.GettableSilence
|
||||
|
||||
// swagger:model
|
||||
// swagger:model gettableAlerts
|
||||
type GettableAlerts = amv2.GettableAlerts
|
||||
|
||||
// swagger:model
|
||||
// swagger:model gettableAlert
|
||||
type GettableAlert = amv2.GettableAlert
|
||||
|
||||
// swagger:model
|
||||
// swagger:model alertGroups
|
||||
type AlertGroups = amv2.AlertGroups
|
||||
|
||||
// swagger:model
|
||||
// swagger:model alertGroup
|
||||
type AlertGroup = amv2.AlertGroup
|
||||
|
||||
// swagger:model
|
||||
// swagger:model receiver
|
||||
type Receiver = amv2.Receiver
|
||||
|
||||
// swagger:parameters RouteGetAMAlerts RouteGetAMAlertGroups
|
||||
|
||||
@@ -57,12 +57,6 @@
|
||||
"type": "object",
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"AlertGroup": {
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
"AlertGroups": {
|
||||
"$ref": "#/definitions/alertGroups"
|
||||
},
|
||||
"AlertInstancesResponse": {
|
||||
"properties": {
|
||||
"instances": {
|
||||
@@ -81,6 +75,38 @@
|
||||
"type": "object",
|
||||
"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": {
|
||||
"properties": {
|
||||
"datasourceUid": {
|
||||
@@ -477,11 +503,18 @@
|
||||
"Failure": {
|
||||
"$ref": "#/definitions/ResponseDetails"
|
||||
},
|
||||
"GettableAlert": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"GettableAlerts": {
|
||||
"$ref": "#/definitions/gettableAlerts"
|
||||
"GettableAlertmanagers": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/AlertManagersResult"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"x-go-name": "Status"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"GettableApiAlertingConfig": {
|
||||
"properties": {
|
||||
@@ -786,8 +819,6 @@
|
||||
"type": "object",
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"GettableSilence": {},
|
||||
"GettableSilences": {},
|
||||
"GettableStatus": {
|
||||
"properties": {
|
||||
"cluster": {
|
||||
@@ -1589,7 +1620,6 @@
|
||||
"type": "object",
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"PostableSilence": {},
|
||||
"PostableUserConfig": {
|
||||
"properties": {
|
||||
"alertmanager_config": {
|
||||
@@ -1724,7 +1754,8 @@
|
||||
}
|
||||
},
|
||||
"title": "Receiver configuration provides configuration on how to contact a receiver.",
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"x-go-package": "github.com/prometheus/alertmanager/config"
|
||||
},
|
||||
"Regexp": {
|
||||
"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"
|
||||
},
|
||||
"receiver": {
|
||||
"$ref": "#/definitions/Receiver"
|
||||
"$ref": "#/definitions/receiver"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2466,18 +2497,14 @@
|
||||
"labels",
|
||||
"receiver"
|
||||
],
|
||||
"type": "object",
|
||||
"x-go-name": "AlertGroup",
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"items": {
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
"type": "array",
|
||||
"x-go-name": "AlertGroups",
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"type": "array"
|
||||
},
|
||||
"alertStatus": {
|
||||
"description": "AlertStatus alert status",
|
||||
@@ -2597,7 +2624,6 @@
|
||||
"$ref": "#/definitions/Duration"
|
||||
},
|
||||
"gettableAlert": {
|
||||
"description": "GettableAlert gettable alert",
|
||||
"properties": {
|
||||
"annotations": {
|
||||
"$ref": "#/definitions/labelSet"
|
||||
@@ -2625,7 +2651,7 @@
|
||||
"receivers": {
|
||||
"description": "receivers",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Receiver"
|
||||
"$ref": "#/definitions/receiver"
|
||||
},
|
||||
"type": "array",
|
||||
"x-go-name": "Receivers"
|
||||
@@ -2661,7 +2687,6 @@
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
@@ -2722,14 +2747,11 @@
|
||||
"status",
|
||||
"updatedAt"
|
||||
],
|
||||
"type": "object",
|
||||
"x-go-name": "GettableSilence",
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"type": "object"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"items": {
|
||||
"$ref": "#/definitions/GettableSilence"
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
},
|
||||
"type": "array",
|
||||
"x-go-name": "GettableSilences",
|
||||
@@ -2862,7 +2884,6 @@
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
},
|
||||
"postableSilence": {
|
||||
"description": "PostableSilence postable silence",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"description": "comment",
|
||||
@@ -2907,7 +2928,6 @@
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
},
|
||||
"receiver": {
|
||||
"description": "Receiver receiver",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "name",
|
||||
@@ -3090,9 +3110,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GettableAlerts",
|
||||
"description": "gettableAlerts",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableAlerts"
|
||||
"$ref": "#/definitions/gettableAlerts"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -3203,9 +3223,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "AlertGroups",
|
||||
"description": "alertGroups",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/AlertGroups"
|
||||
"$ref": "#/definitions/alertGroups"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -3277,9 +3297,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GettableSilence",
|
||||
"description": "gettableSilence",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableSilence"
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -3318,9 +3338,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GettableSilences",
|
||||
"description": "gettableSilences",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableSilences"
|
||||
"$ref": "#/definitions/gettableSilences"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -3355,9 +3375,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "GettableSilence",
|
||||
"description": "gettableSilence",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableSilence"
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
}
|
||||
},
|
||||
"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}": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GettableAlerts",
|
||||
"description": "gettableAlerts",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableAlerts"
|
||||
"$ref": "#/definitions/gettableAlerts"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -188,9 +188,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "AlertGroups",
|
||||
"description": "alertGroups",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/AlertGroups"
|
||||
"$ref": "#/definitions/alertGroups"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -226,9 +226,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GettableSilence",
|
||||
"description": "gettableSilence",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableSilence"
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -303,9 +303,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GettableSilences",
|
||||
"description": "gettableSilences",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableSilences"
|
||||
"$ref": "#/definitions/gettableSilences"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -340,9 +340,9 @@
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "GettableSilence",
|
||||
"description": "gettableSilence",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableSilence"
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
}
|
||||
},
|
||||
"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}": {
|
||||
"post": {
|
||||
"description": "Test receiver",
|
||||
@@ -1015,12 +1035,6 @@
|
||||
},
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"AlertGroup": {
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
"AlertGroups": {
|
||||
"$ref": "#/definitions/alertGroups"
|
||||
},
|
||||
"AlertInstancesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1039,6 +1053,38 @@
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"title": "AlertQuery represents a single query associated with an alert definition.",
|
||||
@@ -1438,11 +1484,18 @@
|
||||
"Failure": {
|
||||
"$ref": "#/definitions/ResponseDetails"
|
||||
},
|
||||
"GettableAlert": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"GettableAlerts": {
|
||||
"$ref": "#/definitions/gettableAlerts"
|
||||
"GettableAlertmanagers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/AlertManagersResult"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"x-go-name": "Status"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"GettableApiAlertingConfig": {
|
||||
"type": "object",
|
||||
@@ -1747,12 +1800,6 @@
|
||||
},
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"GettableSilence": {
|
||||
"$ref": "#/definitions/GettableSilence"
|
||||
},
|
||||
"GettableSilences": {
|
||||
"$ref": "#/definitions/GettableSilences"
|
||||
},
|
||||
"GettableStatus": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2555,9 +2602,6 @@
|
||||
},
|
||||
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
},
|
||||
"PostableSilence": {
|
||||
"$ref": "#/definitions/PostableSilence"
|
||||
},
|
||||
"PostableUserConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2693,7 +2737,7 @@
|
||||
"x-go-name": "WechatConfigs"
|
||||
}
|
||||
},
|
||||
"$ref": "#/definitions/Receiver"
|
||||
"x-go-package": "github.com/prometheus/alertmanager/config"
|
||||
},
|
||||
"Regexp": {
|
||||
"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"
|
||||
},
|
||||
"receiver": {
|
||||
"$ref": "#/definitions/Receiver"
|
||||
"$ref": "#/definitions/receiver"
|
||||
}
|
||||
},
|
||||
"x-go-name": "AlertGroup",
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
@@ -3445,8 +3488,7 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
"x-go-name": "AlertGroups",
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"$ref": "#/definitions/alertGroups"
|
||||
},
|
||||
"alertStatus": {
|
||||
"description": "AlertStatus alert status",
|
||||
@@ -3566,7 +3608,6 @@
|
||||
"$ref": "#/definitions/Duration"
|
||||
},
|
||||
"gettableAlert": {
|
||||
"description": "GettableAlert gettable alert",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"labels",
|
||||
@@ -3606,7 +3647,7 @@
|
||||
"description": "receivers",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Receiver"
|
||||
"$ref": "#/definitions/receiver"
|
||||
},
|
||||
"x-go-name": "Receivers"
|
||||
},
|
||||
@@ -3627,16 +3668,17 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"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": {
|
||||
"description": "GettableSilence gettable silence",
|
||||
@@ -3692,17 +3734,16 @@
|
||||
"x-go-name": "UpdatedAt"
|
||||
}
|
||||
},
|
||||
"x-go-name": "GettableSilence",
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/GettableSilence"
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
},
|
||||
"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": {
|
||||
"description": "LabelSet label set",
|
||||
@@ -3831,7 +3872,6 @@
|
||||
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
|
||||
},
|
||||
"postableSilence": {
|
||||
"description": "PostableSilence postable silence",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"comment",
|
||||
@@ -3873,10 +3913,10 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"description": "Receiver receiver",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
@@ -3889,7 +3929,8 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"description": "Silence silence",
|
||||
|
||||
@@ -10,10 +10,12 @@ type AlertConfiguration struct {
|
||||
ConfigurationVersion string
|
||||
CreatedAt int64 `xorm:"created"`
|
||||
Default bool
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
}
|
||||
|
||||
// GetLatestAlertmanagerConfigurationQuery is the query to get the latest alertmanager configuration.
|
||||
type GetLatestAlertmanagerConfigurationQuery struct {
|
||||
OrgID int64
|
||||
Result *AlertConfiguration
|
||||
}
|
||||
|
||||
@@ -22,8 +24,5 @@ type SaveAlertmanagerConfigurationCmd struct {
|
||||
AlertmanagerConfiguration string
|
||||
ConfigurationVersion string
|
||||
Default bool
|
||||
}
|
||||
|
||||
type DeleteAlertmanagerConfigurationCmd struct {
|
||||
ID int64
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
@@ -81,17 +81,19 @@ func (ng *AlertNG) Init() error {
|
||||
}
|
||||
|
||||
schedCfg := schedule.SchedulerCfg{
|
||||
C: clock.New(),
|
||||
BaseInterval: baseInterval,
|
||||
Logger: log.New("ngalert.scheduler"),
|
||||
MaxAttempts: maxAttempts,
|
||||
Evaluator: eval.Evaluator{Cfg: ng.Cfg, Log: ng.Log},
|
||||
InstanceStore: store,
|
||||
RuleStore: store,
|
||||
AdminConfigStore: store,
|
||||
Notifier: ng.Alertmanager,
|
||||
Metrics: ng.Metrics,
|
||||
C: clock.New(),
|
||||
BaseInterval: baseInterval,
|
||||
Logger: log.New("ngalert.scheduler"),
|
||||
MaxAttempts: maxAttempts,
|
||||
Evaluator: eval.Evaluator{Cfg: ng.Cfg, Log: ng.Log},
|
||||
InstanceStore: store,
|
||||
RuleStore: store,
|
||||
AdminConfigStore: store,
|
||||
Notifier: ng.Alertmanager,
|
||||
Metrics: ng.Metrics,
|
||||
AdminConfigPollInterval: ng.Cfg.AdminConfigPollInterval,
|
||||
}
|
||||
|
||||
ng.stateManager = state.NewManager(ng.Log, ng.Metrics, store, store)
|
||||
ng.schedule = schedule.NewScheduler(schedCfg, ng.DataService, ng.Cfg.AppURL, ng.stateManager)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -72,6 +73,8 @@ const (
|
||||
}
|
||||
}
|
||||
`
|
||||
//TODO: temporary until fix org isolation
|
||||
mainOrgID = 1
|
||||
)
|
||||
|
||||
type Alertmanager struct {
|
||||
@@ -168,7 +171,7 @@ func (am *Alertmanager) Ready() bool {
|
||||
|
||||
func (am *Alertmanager) Run(ctx context.Context) error {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ func (am *Alertmanager) Run(ctx context.Context) error {
|
||||
case <-ctx.Done():
|
||||
return am.StopAndWait()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -203,7 +206,7 @@ func (am *Alertmanager) StopAndWait() error {
|
||||
|
||||
// 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.
|
||||
func (am *Alertmanager) SaveAndApplyDefaultConfig() error {
|
||||
func (am *Alertmanager) SaveAndApplyDefaultConfig(orgID int64) error {
|
||||
am.reloadConfigMtx.Lock()
|
||||
defer am.reloadConfigMtx.Unlock()
|
||||
|
||||
@@ -211,6 +214,7 @@ func (am *Alertmanager) SaveAndApplyDefaultConfig() error {
|
||||
AlertmanagerConfiguration: alertmanagerDefaultConfiguration,
|
||||
Default: true,
|
||||
ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion),
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
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.
|
||||
// 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)
|
||||
if err != nil {
|
||||
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{
|
||||
AlertmanagerConfiguration: string(rawConfig),
|
||||
ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion),
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
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
|
||||
// the components with the new config.
|
||||
func (am *Alertmanager) SyncAndApplyConfigFromDatabase() error {
|
||||
func (am *Alertmanager) SyncAndApplyConfigFromDatabase(orgID int64) error {
|
||||
am.reloadConfigMtx.Lock()
|
||||
defer am.reloadConfigMtx.Unlock()
|
||||
|
||||
// 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 there's no configuration in the database, let's use the default configuration.
|
||||
if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
|
||||
@@ -279,6 +284,7 @@ func (am *Alertmanager) SyncAndApplyConfigFromDatabase() error {
|
||||
AlertmanagerConfiguration: alertmanagerDefaultConfiguration,
|
||||
Default: true,
|
||||
ConfigurationVersion: fmt.Sprintf("v%d", ngmodels.AlertConfigurationVersion),
|
||||
OrgID: orgID,
|
||||
}
|
||||
if err := am.Store.SaveAlertmanagerConfiguration(savecmd); err != nil {
|
||||
return err
|
||||
@@ -399,7 +405,7 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -54,7 +54,7 @@ func setupAMTest(t *testing.T) *Alertmanager {
|
||||
|
||||
func TestAlertmanager_ShouldUseDefaultConfigurationWhenNoConfiguration(t *testing.T) {
|
||||
am := setupAMTest(t)
|
||||
require.NoError(t, am.SyncAndApplyConfigFromDatabase())
|
||||
require.NoError(t, am.SyncAndApplyConfigFromDatabase(mainOrgID))
|
||||
require.NotNil(t, am.config)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,13 @@ import (
|
||||
// timeNow makes it possible to test usage of time
|
||||
var timeNow = time.Now
|
||||
|
||||
// AdminConfigPollingInterval of how often we sync admin configuration.
|
||||
var AdminConfigPollingInterval = 1 * time.Minute
|
||||
|
||||
// ScheduleService handles scheduling
|
||||
type ScheduleService interface {
|
||||
Run(context.Context) error
|
||||
Pause() error
|
||||
Unpause() error
|
||||
AlertmanagersFor(orgID int64) []*url.URL
|
||||
DroppedAlertmanagersFor(orgID int64) []*url.URL
|
||||
|
||||
// the following are used by tests only used for tests
|
||||
evalApplied(models.AlertRuleKey, time.Time)
|
||||
@@ -85,50 +84,53 @@ type schedule struct {
|
||||
metrics *metrics.Metrics
|
||||
|
||||
// Senders help us send alerts to external Alertmanagers.
|
||||
sendersMtx sync.RWMutex
|
||||
sendersCfgHash map[int64]string
|
||||
senders map[int64]*sender.Sender
|
||||
sendersMtx sync.RWMutex
|
||||
sendersCfgHash map[int64]string
|
||||
senders map[int64]*sender.Sender
|
||||
adminConfigPollInterval time.Duration
|
||||
}
|
||||
|
||||
// SchedulerCfg is the scheduler configuration.
|
||||
type SchedulerCfg struct {
|
||||
C clock.Clock
|
||||
BaseInterval time.Duration
|
||||
Logger log.Logger
|
||||
EvalAppliedFunc func(models.AlertRuleKey, time.Time)
|
||||
MaxAttempts int64
|
||||
StopAppliedFunc func(models.AlertRuleKey)
|
||||
Evaluator eval.Evaluator
|
||||
RuleStore store.RuleStore
|
||||
InstanceStore store.InstanceStore
|
||||
AdminConfigStore store.AdminConfigurationStore
|
||||
Notifier Notifier
|
||||
Metrics *metrics.Metrics
|
||||
C clock.Clock
|
||||
BaseInterval time.Duration
|
||||
Logger log.Logger
|
||||
EvalAppliedFunc func(models.AlertRuleKey, time.Time)
|
||||
MaxAttempts int64
|
||||
StopAppliedFunc func(models.AlertRuleKey)
|
||||
Evaluator eval.Evaluator
|
||||
RuleStore store.RuleStore
|
||||
InstanceStore store.InstanceStore
|
||||
AdminConfigStore store.AdminConfigurationStore
|
||||
Notifier Notifier
|
||||
Metrics *metrics.Metrics
|
||||
AdminConfigPollInterval time.Duration
|
||||
}
|
||||
|
||||
// NewScheduler returns a new 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()))
|
||||
sch := schedule{
|
||||
registry: alertRuleRegistry{alertRuleInfo: make(map[models.AlertRuleKey]alertRuleInfo)},
|
||||
maxAttempts: cfg.MaxAttempts,
|
||||
clock: cfg.C,
|
||||
baseInterval: cfg.BaseInterval,
|
||||
log: cfg.Logger,
|
||||
heartbeat: ticker,
|
||||
evalAppliedFunc: cfg.EvalAppliedFunc,
|
||||
stopAppliedFunc: cfg.StopAppliedFunc,
|
||||
evaluator: cfg.Evaluator,
|
||||
ruleStore: cfg.RuleStore,
|
||||
instanceStore: cfg.InstanceStore,
|
||||
dataService: dataService,
|
||||
adminConfigStore: cfg.AdminConfigStore,
|
||||
notifier: cfg.Notifier,
|
||||
metrics: cfg.Metrics,
|
||||
appURL: appURL,
|
||||
stateManager: stateManager,
|
||||
senders: map[int64]*sender.Sender{},
|
||||
sendersCfgHash: map[int64]string{},
|
||||
registry: alertRuleRegistry{alertRuleInfo: make(map[models.AlertRuleKey]alertRuleInfo)},
|
||||
maxAttempts: cfg.MaxAttempts,
|
||||
clock: cfg.C,
|
||||
baseInterval: cfg.BaseInterval,
|
||||
log: cfg.Logger,
|
||||
heartbeat: ticker,
|
||||
evalAppliedFunc: cfg.EvalAppliedFunc,
|
||||
stopAppliedFunc: cfg.StopAppliedFunc,
|
||||
evaluator: cfg.Evaluator,
|
||||
ruleStore: cfg.RuleStore,
|
||||
instanceStore: cfg.InstanceStore,
|
||||
dataService: dataService,
|
||||
adminConfigStore: cfg.AdminConfigStore,
|
||||
notifier: cfg.Notifier,
|
||||
metrics: cfg.Metrics,
|
||||
appURL: appURL,
|
||||
stateManager: stateManager,
|
||||
senders: map[int64]*sender.Sender{},
|
||||
sendersCfgHash: map[int64]string{},
|
||||
adminConfigPollInterval: cfg.AdminConfigPollInterval,
|
||||
}
|
||||
return &sch
|
||||
}
|
||||
@@ -290,7 +292,7 @@ func (sch *schedule) DroppedAlertmanagersFor(orgID int64) []*url.URL {
|
||||
func (sch *schedule) adminConfigSync(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-time.After(AdminConfigPollingInterval):
|
||||
case <-time.After(sch.adminConfigPollInterval):
|
||||
if err := sch.SyncAndApplyConfigFromDatabase(); err != nil {
|
||||
sch.log.Error("unable to sync admin configuration", "err", err)
|
||||
}
|
||||
|
||||
@@ -99,9 +99,10 @@ func TestWarmStateCache(t *testing.T) {
|
||||
BaseInterval: time.Second,
|
||||
Logger: log.New("ngalert cache warming test"),
|
||||
|
||||
RuleStore: dbstore,
|
||||
InstanceStore: dbstore,
|
||||
Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
|
||||
RuleStore: dbstore,
|
||||
InstanceStore: dbstore,
|
||||
Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
|
||||
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, nilMetrics, dbstore, dbstore)
|
||||
st.Warm()
|
||||
@@ -143,10 +144,11 @@ func TestAlertingTicker(t *testing.T) {
|
||||
StopAppliedFunc: func(alertDefKey models.AlertRuleKey) {
|
||||
stopAppliedCh <- alertDefKey
|
||||
},
|
||||
RuleStore: dbstore,
|
||||
InstanceStore: dbstore,
|
||||
Logger: log.New("ngalert schedule test"),
|
||||
Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
|
||||
RuleStore: dbstore,
|
||||
InstanceStore: dbstore,
|
||||
Logger: log.New("ngalert schedule test"),
|
||||
Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
|
||||
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, nilMetrics, dbstore, dbstore)
|
||||
sched := schedule.NewScheduler(schedCfg, nil, "http://localhost", st)
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
func TestSendingToExternalAlertmanager(t *testing.T) {
|
||||
t.Cleanup(registry.ClearOverrides)
|
||||
|
||||
fakeAM := newFakeExternalAlertmanager(t)
|
||||
fakeAM := NewFakeExternalAlertmanager(t)
|
||||
defer fakeAM.Close()
|
||||
fakeRuleStore := newFakeRuleStore(t)
|
||||
fakeInstanceStore := &fakeInstanceStore{}
|
||||
@@ -61,7 +61,6 @@ func TestSendingToExternalAlertmanager(t *testing.T) {
|
||||
cancel()
|
||||
})
|
||||
go func() {
|
||||
AdminConfigPollingInterval = 10 * time.Minute // Do not poll in unit tests.
|
||||
err := sched.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
@@ -95,7 +94,7 @@ func TestSendingToExternalAlertmanager(t *testing.T) {
|
||||
func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
|
||||
t.Cleanup(registry.ClearOverrides)
|
||||
|
||||
fakeAM := newFakeExternalAlertmanager(t)
|
||||
fakeAM := NewFakeExternalAlertmanager(t)
|
||||
defer fakeAM.Close()
|
||||
fakeRuleStore := newFakeRuleStore(t)
|
||||
fakeInstanceStore := &fakeInstanceStore{}
|
||||
@@ -130,7 +129,6 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
|
||||
cancel()
|
||||
})
|
||||
go func() {
|
||||
AdminConfigPollingInterval = 10 * time.Minute // Do not poll in unit tests.
|
||||
err := sched.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
@@ -161,7 +159,7 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
|
||||
}, 20*time.Second, 200*time.Millisecond)
|
||||
|
||||
// 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}}
|
||||
cmd = store.UpdateAdminConfigurationCmd{AdminConfiguration: adminConfig2}
|
||||
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")
|
||||
nilMetrics := metrics.NewMetrics(nil)
|
||||
schedCfg := SchedulerCfg{
|
||||
C: mockedClock,
|
||||
BaseInterval: time.Second,
|
||||
MaxAttempts: 1,
|
||||
Evaluator: eval.Evaluator{Cfg: &setting.Cfg{ExpressionsEnabled: true}, Log: logger},
|
||||
RuleStore: rs,
|
||||
InstanceStore: is,
|
||||
AdminConfigStore: acs,
|
||||
Notifier: &fakeNotifier{},
|
||||
Logger: logger,
|
||||
Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
|
||||
C: mockedClock,
|
||||
BaseInterval: time.Second,
|
||||
MaxAttempts: 1,
|
||||
Evaluator: eval.Evaluator{Cfg: &setting.Cfg{ExpressionsEnabled: true}, Log: logger},
|
||||
RuleStore: rs,
|
||||
InstanceStore: is,
|
||||
AdminConfigStore: acs,
|
||||
Notifier: &fakeNotifier{},
|
||||
Logger: logger,
|
||||
Metrics: metrics.NewMetrics(prometheus.NewRegistry()),
|
||||
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, nilMetrics, rs, is)
|
||||
return NewScheduler(schedCfg, nil, "http://localhost", st), mockedClock
|
||||
|
||||
@@ -237,17 +237,17 @@ func (n *fakeNotifier) PutAlerts(alerts apimodels.PostableAlerts) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeExternalAlertmanager struct {
|
||||
type FakeExternalAlertmanager struct {
|
||||
t *testing.T
|
||||
mtx sync.Mutex
|
||||
alerts amv2.PostableAlerts
|
||||
server *httptest.Server
|
||||
}
|
||||
|
||||
func newFakeExternalAlertmanager(t *testing.T) *fakeExternalAlertmanager {
|
||||
func NewFakeExternalAlertmanager(t *testing.T) *FakeExternalAlertmanager {
|
||||
t.Helper()
|
||||
|
||||
am := &fakeExternalAlertmanager{
|
||||
am := &FakeExternalAlertmanager{
|
||||
t: t,
|
||||
alerts: amv2.PostableAlerts{},
|
||||
}
|
||||
@@ -256,7 +256,11 @@ func newFakeExternalAlertmanager(t *testing.T) *fakeExternalAlertmanager {
|
||||
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{}
|
||||
alerts := am.Alerts()
|
||||
|
||||
@@ -275,20 +279,20 @@ func (am *fakeExternalAlertmanager) AlertNamesCompare(expected []string) bool {
|
||||
return assert.ObjectsAreEqual(expected, n)
|
||||
}
|
||||
|
||||
func (am *fakeExternalAlertmanager) AlertsCount() int {
|
||||
func (am *FakeExternalAlertmanager) AlertsCount() int {
|
||||
am.mtx.Lock()
|
||||
defer am.mtx.Unlock()
|
||||
|
||||
return len(am.alerts)
|
||||
}
|
||||
|
||||
func (am *fakeExternalAlertmanager) Alerts() amv2.PostableAlerts {
|
||||
func (am *FakeExternalAlertmanager) Alerts() amv2.PostableAlerts {
|
||||
am.mtx.Lock()
|
||||
defer am.mtx.Unlock()
|
||||
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) {
|
||||
b, err := ioutil.ReadAll(r.Body)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func (st *Manager) setNextState(alertRule *ngModels.AlertRule, result eval.Resul
|
||||
|
||||
st.set(currentState)
|
||||
if oldState != currentState.State {
|
||||
go st.createAlertAnnotation(currentState.State, alertRule, result)
|
||||
go st.createAlertAnnotation(currentState.State, alertRule, result, oldState)
|
||||
}
|
||||
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())
|
||||
dashUid, ok := alertRule.Annotations["__dashboardUid__"]
|
||||
if !ok {
|
||||
@@ -255,12 +255,14 @@ func (st *Manager) createAlertAnnotation(new eval.State, alertRule *ngModels.Ale
|
||||
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{
|
||||
OrgId: alertRule.OrgID,
|
||||
DashboardId: query.Result.Id,
|
||||
PanelId: panelId,
|
||||
PrevState: oldState.String(),
|
||||
NewState: new.String(),
|
||||
Text: annotationText,
|
||||
Epoch: result.EvaluatedAt.UnixNano() / int64(time.Millisecond),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func (st *DBstore) GetLatestAlertmanagerConfiguration(query *models.GetLatestAle
|
||||
return st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
c := &models.AlertConfiguration{}
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
@@ -48,6 +48,7 @@ func (st DBstore) SaveAlertmanagerConfigurationWithCallback(cmd *models.SaveAler
|
||||
AlertmanagerConfiguration: cmd.AlertmanagerConfiguration,
|
||||
ConfigurationVersion: cmd.ConfigurationVersion,
|
||||
Default: cmd.Default,
|
||||
OrgID: cmd.OrgID,
|
||||
}
|
||||
if _, err := sess.Insert(config); err != nil {
|
||||
return err
|
||||
|
||||
@@ -41,6 +41,7 @@ func AddMigrations(mg *Migrator) {
|
||||
ualert.AddTablesMigrations(mg)
|
||||
ualert.AddDashAlertMigration(mg)
|
||||
addLibraryElementsMigrations(mg)
|
||||
ualert.RerunDashAlertMigration(mg)
|
||||
}
|
||||
|
||||
func addMigrationLogMigrations(mg *Migrator) {
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
)
|
||||
|
||||
type alertRule struct {
|
||||
OrgId int64
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
Title string
|
||||
Condition string
|
||||
Data []alertQuery
|
||||
IntervalSeconds int64
|
||||
Version int64
|
||||
Uid string
|
||||
NamespaceUid string
|
||||
UID string `xorm:"uid"`
|
||||
NamespaceUID string `xorm:"namespace_uid"`
|
||||
RuleGroup string
|
||||
NoDataState string
|
||||
ExecErrState string
|
||||
@@ -51,9 +51,9 @@ type alertRuleVersion struct {
|
||||
|
||||
func (a *alertRule) makeVersion() *alertRuleVersion {
|
||||
return &alertRuleVersion{
|
||||
RuleOrgID: a.OrgId,
|
||||
RuleUID: a.Uid,
|
||||
RuleNamespaceUID: a.NamespaceUid,
|
||||
RuleOrgID: a.OrgID,
|
||||
RuleUID: a.UID,
|
||||
RuleNamespaceUID: a.NamespaceUID,
|
||||
RuleGroup: a.RuleGroup,
|
||||
ParentVersion: 0,
|
||||
RestoredFrom: 0,
|
||||
@@ -96,14 +96,14 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string
|
||||
annotations["message"] = da.Message
|
||||
|
||||
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.
|
||||
Uid: util.GenerateShortUID(),
|
||||
UID: util.GenerateShortUID(),
|
||||
Condition: cond.Condition,
|
||||
Data: cond.Data,
|
||||
IntervalSeconds: ruleAdjustInterval(da.Frequency),
|
||||
Version: 1,
|
||||
NamespaceUid: folderUID, // Folder already created, comes from env var.
|
||||
NamespaceUID: folderUID, // Folder already created, comes from env var.
|
||||
RuleGroup: da.Name,
|
||||
For: duration(da.For),
|
||||
Updated: time.Now().UTC(),
|
||||
@@ -123,7 +123,7 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string
|
||||
}
|
||||
|
||||
// Label for routing and silences.
|
||||
n, v := getLabelForRouteMatching(ar.Uid)
|
||||
n, v := getLabelForRouteMatching(ar.UID)
|
||||
ar.Labels[n] = v
|
||||
|
||||
if err := m.addSilence(da, ar); err != nil {
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
)
|
||||
|
||||
type notificationChannel struct {
|
||||
ID int `xorm:"id"`
|
||||
ID int64 `xorm:"id"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
Uid string `xorm:"uid"`
|
||||
Name string `xorm:"name"`
|
||||
Type string `xorm:"type"`
|
||||
@@ -27,9 +28,16 @@ type notificationChannel struct {
|
||||
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 := `
|
||||
SELECT id,
|
||||
org_id,
|
||||
uid,
|
||||
name,
|
||||
type,
|
||||
@@ -50,25 +58,27 @@ func (m *migration) getNotificationChannelMap() (map[interface{}]*notificationCh
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
allChannelsMap := make(map[interface{}]*notificationChannel)
|
||||
var defaultChannels []*notificationChannel
|
||||
allChannelsMap := make(channelsPerOrg)
|
||||
defaultChannelsMap := make(defaultChannelsPerOrg)
|
||||
for i, c := range allChannels {
|
||||
if _, ok := allChannelsMap[c.OrgID]; !ok { // new seen org
|
||||
allChannelsMap[c.OrgID] = make(map[interface{}]*notificationChannel)
|
||||
}
|
||||
if c.Uid != "" {
|
||||
allChannelsMap[c.Uid] = &allChannels[i]
|
||||
allChannelsMap[c.OrgID][c.Uid] = &allChannels[i]
|
||||
}
|
||||
if c.ID != 0 {
|
||||
allChannelsMap[c.ID] = &allChannels[i]
|
||||
allChannelsMap[c.OrgID][c.ID] = &allChannels[i]
|
||||
}
|
||||
if c.IsDefault {
|
||||
// TODO: verify that there will be only 1 default channel.
|
||||
defaultChannels = append(defaultChannels, &allChannels[i])
|
||||
defaultChannelsMap[c.OrgID] = append(defaultChannelsMap[c.OrgID], &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.
|
||||
if allChannels == nil {
|
||||
return nil
|
||||
@@ -82,7 +92,7 @@ func (m *migration) updateReceiverAndRoute(allChannels map[interface{}]*notifica
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -97,7 +107,7 @@ func (m *migration) updateReceiverAndRoute(allChannels map[interface{}]*notifica
|
||||
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{}
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
portedChannels = append(portedChannels, &PostableGrafanaReceiver{
|
||||
UID: uid,
|
||||
@@ -129,9 +142,10 @@ func (m *migration) makeReceiverAndRoute(ruleUid string, channelUids []interface
|
||||
// Remove obsolete notification channels.
|
||||
filteredChannelUids := make(map[interface{}]struct{})
|
||||
for _, uid := range channelUids {
|
||||
_, ok := allChannels[uid]
|
||||
c, ok := allChannels[uid]
|
||||
if ok {
|
||||
filteredChannelUids[uid] = struct{}{}
|
||||
// always store the channel UID to prevent duplicates
|
||||
filteredChannelUids[c.Uid] = struct{}{}
|
||||
} else {
|
||||
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 == "" {
|
||||
id = c.ID
|
||||
}
|
||||
_, ok := allChannels[id]
|
||||
c, ok := allChannels[id]
|
||||
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
|
||||
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.
|
||||
receiverName = rn
|
||||
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)
|
||||
}
|
||||
|
||||
m.portedChannelGroups[chanKey] = receiverName
|
||||
m.portedChannelGroupsPerOrg[orgID][chanKey] = receiverName
|
||||
receiver = &PostableApiReceiver{
|
||||
Name: receiverName,
|
||||
GrafanaManagedReceivers: portedChannels,
|
||||
@@ -220,32 +239,47 @@ func makeKeyForChannelGroup(channelUids map[interface{}]struct{}) (string, error
|
||||
}
|
||||
|
||||
// addDefaultChannels should be called before adding any other routes.
|
||||
func (m *migration) addDefaultChannels(amConfig *PostableUserConfig, allChannels map[interface{}]*notificationChannel, defaultChannels []*notificationChannel) error {
|
||||
// Default route and receiver.
|
||||
recv, route, err := m.makeReceiverAndRoute("default_route", nil, defaultChannels, allChannels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func (m *migration) addDefaultChannels(amConfigsPerOrg amConfigsPerOrg, allChannels channelsPerOrg, defaultChannels defaultChannelsPerOrg) error {
|
||||
for orgID := range allChannels {
|
||||
if _, ok := amConfigsPerOrg[orgID]; !ok {
|
||||
amConfigsPerOrg[orgID] = &PostableUserConfig{
|
||||
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 {
|
||||
amConfig.AlertmanagerConfig.Receivers = append(amConfig.AlertmanagerConfig.Receivers, recv)
|
||||
if recv != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
portedChannels := []*PostableGrafanaReceiver{}
|
||||
receiver := &PostableApiReceiver{
|
||||
Name: "autogen-unlinked-channel-recv",
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
@@ -259,7 +293,7 @@ func (m *migration) addUnmigratedChannels(amConfig *PostableUserConfig, allChann
|
||||
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)
|
||||
portedChannels = append(portedChannels, &PostableGrafanaReceiver{
|
||||
UID: uid,
|
||||
@@ -272,7 +306,7 @@ func (m *migration) addUnmigratedChannels(amConfig *PostableUserConfig, allChann
|
||||
}
|
||||
receiver.GrafanaManagedReceivers = portedChannels
|
||||
if len(portedChannels) > 0 {
|
||||
amConfig.AlertmanagerConfig.Receivers = append(amConfig.AlertmanagerConfig.Receivers, receiver)
|
||||
amConfigs.AlertmanagerConfig.Receivers = append(amConfigs.AlertmanagerConfig.Receivers, receiver)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -361,6 +395,8 @@ type PostableUserConfig struct {
|
||||
AlertmanagerConfig PostableApiAlertingConfig `yaml:"alertmanager_config" json:"alertmanager_config"`
|
||||
}
|
||||
|
||||
type amConfigsPerOrg = map[int64]*PostableUserConfig
|
||||
|
||||
func (c *PostableUserConfig) EncryptSecureSettings() error {
|
||||
for _, r := range c.AlertmanagerConfig.Receivers {
|
||||
for _, gr := range r.GrafanaManagedReceivers {
|
||||
|
||||
@@ -61,6 +61,25 @@ func (m *migration) getOrCreateGeneralFolder(orgID int64) (*dashboard, error) {
|
||||
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()
|
||||
// it should be called from inside a transaction
|
||||
func (m *migration) createFolder(orgID int64, title string) (*dashboard, error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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")
|
||||
}
|
||||
|
||||
n, v := getLabelForRouteMatching(rule.Uid)
|
||||
n, v := getLabelForRouteMatching(rule.UID)
|
||||
s := &pb.MeshSilence{
|
||||
Silence: &pb.Silence{
|
||||
Id: uid.String(),
|
||||
@@ -50,7 +51,7 @@ func (m *migration) addSilence(da dashAlert, rule *alertRule) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *migration) writeSilencesFile() error {
|
||||
func (m *migration) writeSilencesFile(orgID int64) error {
|
||||
var buf bytes.Buffer
|
||||
for _, e := range m.silences {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -70,8 +71,12 @@ func (m *migration) writeSilencesFile() error {
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func silencesFileName(mg *migrator.Migrator) string {
|
||||
return filepath.Join(mg.Cfg.DataPath, "alerting", "silences")
|
||||
func getSilenceFileNamesForAllOrgs(mg *migrator.Migrator) ([]string, error) {
|
||||
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.
|
||||
@@ -94,6 +99,10 @@ func (f *replaceFile) Close() error {
|
||||
func openReplace(filename string) (*replaceFile, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
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("").
|
||||
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) {
|
||||
|
||||
@@ -23,6 +23,8 @@ var migTitle = "move dashboard alerts to unified alerting"
|
||||
|
||||
var rmMigTitle = "remove unified alerting data"
|
||||
|
||||
const clearMigrationEntryTitle = "clear migration entry %q"
|
||||
|
||||
type MigrationError struct {
|
||||
AlertId int64
|
||||
Err error
|
||||
@@ -49,19 +51,23 @@ func AddDashAlertMigration(mg *migrator.Migrator) {
|
||||
case ngEnabled && !migrationRun:
|
||||
// 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.
|
||||
err = mg.ClearMigrationEntry(rmMigTitle)
|
||||
mg.AddMigration(fmt.Sprintf(clearMigrationEntryTitle, rmMigTitle), &clearMigrationEntry{
|
||||
migrationID: rmMigTitle,
|
||||
})
|
||||
if err != nil {
|
||||
mg.Logger.Error("alert migration error: could not clear alert migration for removing data", "error", err)
|
||||
}
|
||||
mg.AddMigration(migTitle, &migration{
|
||||
seenChannelUIDs: make(map[string]struct{}),
|
||||
migratedChannels: make(map[*notificationChannel]struct{}),
|
||||
portedChannelGroups: make(map[string]string),
|
||||
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.
|
||||
err = mg.ClearMigrationEntry(migTitle)
|
||||
mg.AddMigration(fmt.Sprintf(clearMigrationEntryTitle, migTitle), &clearMigrationEntry{
|
||||
migrationID: migTitle,
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
migrator.MigrationBase
|
||||
// session and mg are attached for convenience.
|
||||
sess *xorm.Session
|
||||
mg *migrator.Migrator
|
||||
|
||||
seenChannelUIDs map[string]struct{}
|
||||
migratedChannels map[*notificationChannel]struct{}
|
||||
silences []*pb.MeshSilence
|
||||
portedChannelGroups map[string]string // Channel group key -> receiver name.
|
||||
lastReceiverID int // For the auto generated receivers.
|
||||
seenChannelUIDs map[string]struct{}
|
||||
migratedChannelsPerOrg map[int64]map[*notificationChannel]struct{}
|
||||
silences []*pb.MeshSilence
|
||||
portedChannelGroupsPerOrg map[int64]map[string]string // Org -> Channel group key -> receiver name.
|
||||
lastReceiverID int // For the auto generated receivers.
|
||||
}
|
||||
|
||||
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, defaultChannels, err := m.getNotificationChannelMap()
|
||||
allChannelsPerOrg, defaultChannelsPerOrg, err := m.getNotificationChannelMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
amConfig := PostableUserConfig{}
|
||||
err = m.addDefaultChannels(&amConfig, allChannels, defaultChannels)
|
||||
amConfigPerOrg := make(amConfigsPerOrg, len(allChannelsPerOrg))
|
||||
err = m.addDefaultChannels(amConfigPerOrg, allChannelsPerOrg, defaultChannelsPerOrg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -144,26 +217,11 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) 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 MigrationError{
|
||||
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,
|
||||
}
|
||||
folder, err := m.getFolder(dash, da)
|
||||
if err != nil {
|
||||
return MigrationError{
|
||||
Err: err,
|
||||
AlertId: da.Id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,8 +278,12 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.updateReceiverAndRoute(allChannels, defaultChannels, da, rule, &amConfig); err != nil {
|
||||
return err
|
||||
if _, ok := amConfigPerOrg[rule.OrgID]; !ok {
|
||||
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) {
|
||||
@@ -234,8 +296,8 @@ func (m *migration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
|
||||
}
|
||||
if err != nil {
|
||||
// TODO better error handling, if constraint
|
||||
rule.Title += fmt.Sprintf(" %v", rule.Uid)
|
||||
rule.RuleGroup += fmt.Sprintf(" %v", rule.Uid)
|
||||
rule.Title += fmt.Sprintf(" %v", rule.UID)
|
||||
rule.RuleGroup += fmt.Sprintf(" %v", rule.UID)
|
||||
|
||||
_, err = m.sess.Insert(rule)
|
||||
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.
|
||||
err = m.addUnmigratedChannels(&amConfig, allChannels, defaultChannels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for orgID, amConfig := range amConfigPerOrg {
|
||||
// Create a separate receiver for all the unmigrated channels.
|
||||
err = m.addUnmigratedChannels(orgID, amConfig, allChannelsPerOrg[orgID], defaultChannelsPerOrg[orgID])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.writeAlertmanagerConfig(&amConfig, allChannels); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.writeAlertmanagerConfig(orgID, amConfig, allChannelsPerOrg[orgID]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.writeSilencesFile(); err != nil {
|
||||
m.mg.Logger.Error("alert migration error: failed to write silence file", "err", err)
|
||||
if err := m.writeSilencesFile(orgID); err != nil {
|
||||
m.mg.Logger.Error("alert migration error: failed to write silence file", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// No channels, hence don't require 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
|
||||
// the v1 config.
|
||||
ConfigurationVersion: "v1",
|
||||
OrgID: orgID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -297,13 +362,15 @@ func (m *migration) writeAlertmanagerConfig(amConfig *PostableUserConfig, allCha
|
||||
}
|
||||
|
||||
type AlertConfiguration struct {
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
|
||||
AlertmanagerConfiguration string
|
||||
ConfigurationVersion string
|
||||
CreatedAt int64 `xorm:"created"`
|
||||
}
|
||||
|
||||
// rmMigration removes Grafana 8 alert data
|
||||
type rmMigration struct {
|
||||
migrator.MigrationBase
|
||||
}
|
||||
@@ -343,9 +410,23 @@ func (m *rmMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(silencesFileName(mg)); err != nil {
|
||||
mg.Logger.Error("alert migration error: failed to remove silence file", "err", err)
|
||||
files, err := getSilenceFileNamesForAllOrgs(mg)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (m *MigrationBase) SkipMigrationLog() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type RawSQLMigration struct {
|
||||
MigrationBase
|
||||
|
||||
|
||||
@@ -108,13 +108,17 @@ func (mg *Migrator) Start() error {
|
||||
if err != nil {
|
||||
mg.Logger.Error("Exec failed", "error", err, "sql", sql)
|
||||
record.Error = err.Error()
|
||||
if _, err := sess.Insert(&record); err != nil {
|
||||
return err
|
||||
if !m.SkipMigrationLog() {
|
||||
if _, err := sess.Insert(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
record.Success = true
|
||||
_, err = sess.Insert(&record)
|
||||
if !m.SkipMigrationLog() {
|
||||
_, err = sess.Insert(&record)
|
||||
}
|
||||
if err == nil {
|
||||
migrationsPerformed++
|
||||
}
|
||||
@@ -171,16 +175,6 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
|
||||
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
|
||||
|
||||
func (mg *Migrator) InTransaction(callback dbTransactionFunc) error {
|
||||
|
||||
@@ -19,6 +19,10 @@ type Migration interface {
|
||||
Id() string
|
||||
SetId(string)
|
||||
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 {
|
||||
|
||||
@@ -405,6 +405,9 @@ type Cfg struct {
|
||||
// Geomap base layer config
|
||||
GeomapDefaultBaseLayerConfig map[string]interface{}
|
||||
GeomapEnableCustomBaseLayers bool
|
||||
|
||||
// Unified Alerting
|
||||
AdminConfigPollInterval time.Duration
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if err := cfg.readUnifiedAlertingSettings(iniFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
explore := iniFile.Section("explore")
|
||||
ExploreEnabled = explore.Key("enabled").MustBool(true)
|
||||
|
||||
@@ -1349,6 +1356,13 @@ func readRenderingSettings(iniFile *ini.File, cfg *Cfg) error {
|
||||
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 {
|
||||
alerting := iniFile.Section("alerting")
|
||||
AlertingEnabled = alerting.Key("enabled").MustBool(true)
|
||||
|
||||
@@ -31,28 +31,39 @@ func normalizeAzureCloud(cloudName string) string {
|
||||
switch strings.ToLower(cloudName) {
|
||||
// Public
|
||||
case "azurecloud":
|
||||
fallthrough
|
||||
case "azurepublic":
|
||||
fallthrough
|
||||
case "azurepubliccloud":
|
||||
fallthrough
|
||||
case "public":
|
||||
return AzurePublic
|
||||
|
||||
// China
|
||||
case "azurechina":
|
||||
fallthrough
|
||||
case "azurechinacloud":
|
||||
fallthrough
|
||||
case "china":
|
||||
return AzureChina
|
||||
|
||||
// US Government
|
||||
case "azureusgovernment":
|
||||
fallthrough
|
||||
case "azureusgovernmentcloud":
|
||||
fallthrough
|
||||
case "usgov":
|
||||
fallthrough
|
||||
case "usgovernment":
|
||||
return AzureUSGovernment
|
||||
|
||||
// Germany
|
||||
case "azuregermancloud":
|
||||
fallthrough
|
||||
case "azuregermany":
|
||||
fallthrough
|
||||
case "german":
|
||||
fallthrough
|
||||
case "germany":
|
||||
return AzureGermany
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
@@ -16,12 +18,34 @@ import (
|
||||
func TestAlertmanagerConfigurationIsTransactional(t *testing.T) {
|
||||
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
EnableFeatureToggles: []string{"ngalert"},
|
||||
AnonymousUserRole: models.ROLE_EDITOR,
|
||||
DisableAnonymous: true,
|
||||
})
|
||||
|
||||
store := testinfra.SetUpDatabase(t, dir)
|
||||
// override bus to get the GetSignedInUserQuery handler
|
||||
store.Bus = bus.GetBus()
|
||||
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.
|
||||
{
|
||||
@@ -66,17 +90,48 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) {
|
||||
resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint
|
||||
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) {
|
||||
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
EnableFeatureToggles: []string{"ngalert"},
|
||||
AnonymousUserRole: models.ROLE_EDITOR,
|
||||
DisableAnonymous: true,
|
||||
})
|
||||
|
||||
store := testinfra.SetUpDatabase(t, dir)
|
||||
// override bus to get the GetSignedInUserQuery handler
|
||||
store.Bus = bus.GetBus()
|
||||
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 := ""
|
||||
|
||||
// create a new configuration that has a secret
|
||||
|
||||
@@ -39,9 +39,21 @@ func TestAMConfigAccess(t *testing.T) {
|
||||
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
|
||||
|
||||
// Create a users to make authenticated requests
|
||||
require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer"))
|
||||
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor"))
|
||||
require.NoError(t, createUser(t, store, models.ROLE_ADMIN, "admin", "admin"))
|
||||
createUser(t, store, models.CreateUserCommand{
|
||||
DefaultOrgRole: string(models.ROLE_VIEWER),
|
||||
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 {
|
||||
desc string
|
||||
@@ -402,7 +414,11 @@ func TestAlertAndGroupsQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -554,9 +570,21 @@ func TestRulerAccess(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a users to make authenticated requests
|
||||
require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer"))
|
||||
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor"))
|
||||
require.NoError(t, createUser(t, store, models.ROLE_ADMIN, "admin", "admin"))
|
||||
createUser(t, store, models.CreateUserCommand{
|
||||
DefaultOrgRole: string(models.ROLE_VIEWER),
|
||||
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.
|
||||
testCases := []struct {
|
||||
@@ -668,8 +696,16 @@ func TestDeleteFolderWithRules(t *testing.T) {
|
||||
namespaceUID, err := createFolder(t, store, 0, "default")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer"))
|
||||
require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor"))
|
||||
createUser(t, store, models.CreateUserCommand{
|
||||
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")
|
||||
|
||||
@@ -815,12 +851,14 @@ func TestAlertRuleCRUD(t *testing.T) {
|
||||
store.Bus = bus.GetBus()
|
||||
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
|
||||
|
||||
err := createUser(t, store, models.ROLE_EDITOR, "grafana", "password")
|
||||
|
||||
require.NoError(t, err)
|
||||
createUser(t, store, models.CreateUserCommand{
|
||||
DefaultOrgRole: string(models.ROLE_EDITOR),
|
||||
Password: "password",
|
||||
Login: "grafana",
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
interval, err := model.ParseDuration("1m")
|
||||
@@ -1827,7 +1865,11 @@ func TestQuota(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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")
|
||||
require.NoError(t, err)
|
||||
@@ -1921,7 +1963,11 @@ func TestEval(t *testing.T) {
|
||||
store.Bus = bus.GetBus()
|
||||
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.
|
||||
_, err := createFolder(t, store, 0, "default")
|
||||
@@ -2338,16 +2384,18 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st
|
||||
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()
|
||||
|
||||
cmd := models.CreateUserCommand{
|
||||
Login: username,
|
||||
Password: password,
|
||||
DefaultOrgRole: string(role),
|
||||
}
|
||||
_, err := store.CreateUser(context.Background(), cmd)
|
||||
return err
|
||||
u, err := store.CreateUser(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
return u.Id
|
||||
}
|
||||
|
||||
func createOrg(t *testing.T, store *sqlstore.SQLStore, name string, userID int64) int64 {
|
||||
org, err := store.CreateOrgWithMember(name, userID)
|
||||
require.NoError(t, err)
|
||||
return org.Id
|
||||
}
|
||||
|
||||
func getLongString(t *testing.T, n int) string {
|
||||
|
||||
@@ -24,7 +24,11 @@ func TestAvailableChannels(t *testing.T) {
|
||||
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
|
||||
|
||||
// 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)
|
||||
// nolint:gosec
|
||||
|
||||
@@ -68,7 +68,11 @@ func TestNotificationChannels(t *testing.T) {
|
||||
bus.AddHandlerCtx("", mockEmail.sendEmailCommandHandlerSync)
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -34,7 +34,11 @@ func TestPrometheusRules(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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")
|
||||
require.NoError(t, err)
|
||||
@@ -270,7 +274,11 @@ func TestPrometheusRulesPermissions(t *testing.T) {
|
||||
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
|
||||
|
||||
// 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.
|
||||
_, err := createFolder(t, store, 0, "folder1")
|
||||
|
||||
@@ -31,7 +31,11 @@ func TestAlertRulePermissions(t *testing.T) {
|
||||
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store)
|
||||
|
||||
// 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.
|
||||
_, err := createFolder(t, store, 0, "folder1")
|
||||
@@ -320,7 +324,11 @@ func TestAlertRuleConflictingTitle(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -56,10 +56,10 @@ func postRequest(t *testing.T, url string, body string, expStatusCode int) *http
|
||||
buf := bytes.NewReader([]byte(body))
|
||||
// nolint:gosec
|
||||
resp, err := http.Post(url, "application/json", buf)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, resp.Body.Close())
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if expStatusCode != resp.StatusCode {
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -224,6 +224,13 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
|
||||
_, err = featureSection.NewKey("enable", strings.Join(o.EnableFeatureToggles, " "))
|
||||
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 != "" {
|
||||
_, err = anonSect.NewKey("org_role", string(o.AnonymousUserRole))
|
||||
require.NoError(t, err)
|
||||
@@ -265,12 +272,13 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
|
||||
}
|
||||
|
||||
type GrafanaOpts struct {
|
||||
EnableCSP bool
|
||||
EnableFeatureToggles []string
|
||||
AnonymousUserRole models.RoleType
|
||||
EnableQuota bool
|
||||
DisableAnonymous bool
|
||||
CatalogAppEnabled bool
|
||||
ViewersCanEdit bool
|
||||
PluginAdminEnabled bool
|
||||
EnableCSP bool
|
||||
EnableFeatureToggles []string
|
||||
NGAlertAdminConfigIntervalSeconds int
|
||||
AnonymousUserRole models.RoleType
|
||||
EnableQuota bool
|
||||
DisableAnonymous bool
|
||||
CatalogAppEnabled bool
|
||||
ViewersCanEdit bool
|
||||
PluginAdminEnabled bool
|
||||
}
|
||||
|
||||
@@ -179,15 +179,38 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query *
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
@@ -228,3 +251,18 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az
|
||||
|
||||
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/cmpopts"
|
||||
"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/plugins"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/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/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/EC2CapacityReservations": {"AvailableInstanceCount", "InstanceUtilization", "TotalInstanceCount", "UsedInstanceCount"},
|
||||
"AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"},
|
||||
|
||||
@@ -37,5 +37,13 @@ func (a ByTime) Less(i, j int) bool {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -12,46 +12,77 @@ import (
|
||||
)
|
||||
|
||||
func TestFrameSort(t *testing.T) {
|
||||
timeA, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 17:04:05.000")
|
||||
timeB, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 16:04:05.000")
|
||||
timeC, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 15:04:05.000")
|
||||
timeVals := []*time.Time{
|
||||
&timeA, &timeB, &timeC,
|
||||
}
|
||||
timeField := data.NewField("@timestamp", nil, timeVals)
|
||||
t.Run("sort simple frame", func(t *testing.T) {
|
||||
timeA, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 17:04:05.000")
|
||||
timeB, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 16:04:05.000")
|
||||
timeC, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 15:04:05.000")
|
||||
timeVals := []*time.Time{
|
||||
&timeA, &timeB, &timeC,
|
||||
}
|
||||
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"),
|
||||
stringField := data.NewField("line", nil, []*string{
|
||||
aws.String("test message 1"),
|
||||
aws.String("test message 2"),
|
||||
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{
|
||||
aws.Float64(20.0),
|
||||
aws.Float64(50.0),
|
||||
aws.Float64(17.0),
|
||||
t.Run("sort with nil", func(t *testing.T) {
|
||||
timeA, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 17:04:05.000")
|
||||
timeB, _ := time.Parse("2006-01-02 15:04:05.000", "2020-03-02 16:04:05.000")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type DatasourceInfo struct {
|
||||
HTTPClientOpts sdkhttpclient.Options
|
||||
URL string
|
||||
HTTPMethod string
|
||||
TimeInterval string
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -84,11 +85,26 @@ func newInstanceSettings() datasource.InstanceFactoryFunc {
|
||||
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{
|
||||
ID: settings.ID,
|
||||
URL: settings.URL,
|
||||
HTTPClientOpts: httpCliOpts,
|
||||
HTTPMethod: httpMethod,
|
||||
TimeInterval: timeInterval,
|
||||
}
|
||||
return mdl, nil
|
||||
}
|
||||
@@ -123,7 +139,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
Responses: backend.Responses{},
|
||||
}
|
||||
|
||||
queries, err := s.parseQuery(req.Queries)
|
||||
queries, err := s.parseQuery(req.Queries, dsInfo)
|
||||
if err != nil {
|
||||
return &result, err
|
||||
}
|
||||
@@ -217,7 +233,7 @@ func formatLegend(metric model.Metric, query *PrometheusQuery) string {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func (s *Service) parseQuery(queries []backend.DataQuery) (
|
||||
func (s *Service) parseQuery(queries []backend.DataQuery, dsInfo *DatasourceInfo) (
|
||||
[]*PrometheusQuery, error) {
|
||||
var intervalMode string
|
||||
var adjustedInterval time.Duration
|
||||
@@ -239,7 +255,7 @@ func (s *Service) parseQuery(queries []backend.DataQuery) (
|
||||
end := queryModel.TimeRange.To
|
||||
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 != ""
|
||||
// Only use stepMode if we have interval in query, otherwise use "min"
|
||||
if hasQueryInterval {
|
||||
@@ -253,7 +269,7 @@ func (s *Service) parseQuery(queries []backend.DataQuery) (
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestPrometheus(t *testing.T) {
|
||||
To: now.Add(12 * time.Hour),
|
||||
}
|
||||
query.TimeRange = timeRange
|
||||
models, err := service.parseQuery([]backend.DataQuery{query})
|
||||
models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*30, models[0].Step)
|
||||
})
|
||||
@@ -74,7 +74,7 @@ func TestPrometheus(t *testing.T) {
|
||||
To: now.Add(12 * time.Hour),
|
||||
}
|
||||
query.TimeRange = timeRange
|
||||
models, err := service.parseQuery([]backend.DataQuery{query})
|
||||
models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*7, models[0].Step)
|
||||
})
|
||||
@@ -92,7 +92,7 @@ func TestPrometheus(t *testing.T) {
|
||||
To: now.Add(12 * time.Hour),
|
||||
}
|
||||
query.TimeRange = timeRange
|
||||
models, err := service.parseQuery([]backend.DataQuery{query})
|
||||
models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*6, models[0].Step)
|
||||
})
|
||||
@@ -110,7 +110,7 @@ func TestPrometheus(t *testing.T) {
|
||||
To: now.Add(12 * time.Hour),
|
||||
}
|
||||
query.TimeRange = timeRange
|
||||
models, err := service.parseQuery([]backend.DataQuery{query})
|
||||
models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*30, models[0].Step)
|
||||
})
|
||||
@@ -128,7 +128,7 @@ func TestPrometheus(t *testing.T) {
|
||||
To: now.Add(12 * time.Hour),
|
||||
}
|
||||
query.TimeRange = timeRange
|
||||
models, err := service.parseQuery([]backend.DataQuery{query})
|
||||
models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*5, models[0].Step)
|
||||
})
|
||||
@@ -140,7 +140,7 @@ func TestPrometheus(t *testing.T) {
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}`)
|
||||
models, err := service.parseQuery([]backend.DataQuery{query})
|
||||
models, err := service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Minute*2, models[0].Step)
|
||||
|
||||
@@ -149,7 +149,7 @@ func TestPrometheus(t *testing.T) {
|
||||
To: now.Add(1 * time.Hour),
|
||||
}
|
||||
query.TimeRange = timeRange
|
||||
models, err = service.parseQuery([]backend.DataQuery{query})
|
||||
models, err = service.parseQuery([]backend.DataQuery{query}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Second*15, models[0].Step)
|
||||
})
|
||||
@@ -160,7 +160,7 @@ func TestPrometheus(t *testing.T) {
|
||||
"format": "time_series",
|
||||
"intervalFactor": 10,
|
||||
"refId": "A"
|
||||
}`)})
|
||||
}`)}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Minute*20, models[0].Step)
|
||||
})
|
||||
@@ -171,10 +171,23 @@ func TestPrometheus(t *testing.T) {
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}`)})
|
||||
}`)}, &DatasourceInfo{})
|
||||
require.NoError(t, err)
|
||||
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 {
|
||||
|
||||
@@ -8,15 +8,15 @@ describe('PasswordField', () => {
|
||||
placeholder: 'enter password',
|
||||
'data-testid': 'password-field',
|
||||
};
|
||||
it('should renders correctly', () => {
|
||||
it('should render correctly', () => {
|
||||
render(<PasswordField {...props} />);
|
||||
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', () => {
|
||||
render(<PasswordField {...props} />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export const PasswordField: FC<Props> = React.forwardRef<HTMLInputElement, Props
|
||||
name={showPassword ? 'eye-slash' : 'eye'}
|
||||
type="button"
|
||||
aria-controls={id}
|
||||
aria-role="switch"
|
||||
role="switch"
|
||||
aria-checked={showPassword}
|
||||
aria-label="Show password"
|
||||
onClick={() => {
|
||||
|
||||
+8
-6
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
it('should transform wide to many', () => {
|
||||
@@ -19,7 +19,7 @@ describe('Prepair time series transformer', () => {
|
||||
format: timeSeriesFormat.TimeSeriesMany,
|
||||
};
|
||||
|
||||
expect(prepareTimeSeries(source, config)).toEqual([
|
||||
expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([
|
||||
toEquableDataFrame({
|
||||
name: 'wide',
|
||||
refId: 'A',
|
||||
@@ -59,7 +59,7 @@ describe('Prepair time series transformer', () => {
|
||||
format: timeSeriesFormat.TimeSeriesMany,
|
||||
};
|
||||
|
||||
expect(prepareTimeSeries(source, config)).toEqual([
|
||||
expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([
|
||||
toEquableDataFrame({
|
||||
name: 'wide',
|
||||
refId: 'A',
|
||||
@@ -107,7 +107,7 @@ describe('Prepair time series transformer', () => {
|
||||
format: timeSeriesFormat.TimeSeriesMany,
|
||||
};
|
||||
|
||||
expect(prepareTimeSeries(source, config)).toEqual([
|
||||
expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([
|
||||
toEquableDataFrame({
|
||||
name: 'wide',
|
||||
refId: 'A',
|
||||
@@ -162,7 +162,9 @@ describe('Prepair time series transformer', () => {
|
||||
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', () => {
|
||||
@@ -191,7 +193,7 @@ describe('Prepair time series transformer', () => {
|
||||
format: timeSeriesFormat.TimeSeriesMany,
|
||||
};
|
||||
|
||||
expect(prepareTimeSeries(source, config)).toEqual([]);
|
||||
expect(prepareTimeSeriesTransformer.transformer(config)(source)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
DataTransformerInfo,
|
||||
SynchronousDataTransformerInfo,
|
||||
DataFrame,
|
||||
FieldType,
|
||||
DataTransformerID,
|
||||
@@ -63,31 +63,30 @@ export function toTimeSeriesMany(data: DataFrame[]): DataFrame[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function prepareTimeSeries(data: DataFrame[], options: PrepareTimeSeriesOptions): DataFrame[] {
|
||||
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> = {
|
||||
export const prepareTimeSeriesTransformer: SynchronousDataTransformerInfo<PrepareTimeSeriesOptions> = {
|
||||
id: DataTransformerID.prepareTimeSeries,
|
||||
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.`,
|
||||
defaultOptions: {},
|
||||
|
||||
/**
|
||||
* Return a modified copy of the series. If the transform is not or should not
|
||||
* be applied, just return the input series
|
||||
*/
|
||||
operator: (options) => (source) => source.pipe(map((data) => prepareTimeSeries(data, options))),
|
||||
operator: (options) => (source) =>
|
||||
source.pipe(map((data) => prepareTimeSeriesTransformer.transformer(options)(data))),
|
||||
|
||||
transformer: (options: PrepareTimeSeriesOptions) => {
|
||||
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 { Provider } from 'react-redux';
|
||||
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 { getAllDataSources } from './utils/config';
|
||||
import { fetchRules } from './api/prometheus';
|
||||
@@ -77,6 +77,7 @@ const ui = {
|
||||
ruleRow: byTestId('row'),
|
||||
expandedContent: byTestId('expanded-content'),
|
||||
rulesFilterInput: byTestId('search-query-input'),
|
||||
moreErrorsButton: byRole('button', { name: /more errors/ }),
|
||||
};
|
||||
|
||||
describe('RuleList', () => {
|
||||
@@ -164,6 +165,10 @@ describe('RuleList', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user