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

This commit is contained in:
Piotr Jamróz
2021-08-16 10:23:54 +02:00
141 changed files with 3672 additions and 1576 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ shellcheck: $(SH_FILES) ## Run checks for shell scripts.
build-docker-dev: ## Build Docker image for development (fast).
@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
+5
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+2 -1
View File
@@ -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
+4
View File
@@ -995,6 +995,8 @@ github.com/grafana/grafana-aws-sdk v0.7.0/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbR
github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60=
github.com/grafana/grafana-plugin-sdk-go v0.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
View File
@@ -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
}
+1 -1
View File
@@ -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 &&
(() => {
+1 -1
View File
@@ -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)
+8 -2
View File
@@ -64,6 +64,12 @@ func runPluginCommand(command func(commandLine utils.CommandLine) error) func(co
}
}
func runCueCommand(command func(commandLine utils.CommandLine) error) func(context *cli.Context) error {
return func(context *cli.Context) error {
return command(&utils.ContextCommandLine{Context: context})
}
}
// Command contains command state.
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",
+1
View File
@@ -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"
-1
View File
@@ -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
}
-1
View File
@@ -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,
},
+16 -7
View File
@@ -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)
}
+25 -4
View File
@@ -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"})
}
+5 -4
View File
@@ -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
+82 -42
View File
@@ -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": [
+91 -50
View File
@@ -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",
+3 -4
View File
@@ -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
}
+12 -10
View File
@@ -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)
+13 -7
View File
@@ -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)
}
+40 -38
View File
@@ -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
+12 -8
View File
@@ -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()
}
+5 -3
View File
@@ -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),
}
+2 -1
View File
@@ -19,7 +19,7 @@ func (st *DBstore) GetLatestAlertmanagerConfiguration(query *models.GetLatestAle
return st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
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) {
+132 -51
View File
@@ -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
+7 -13
View File
@@ -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 {
+4
View File
@@ -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 {
+14
View File
@@ -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)
+11
View File
@@ -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
}
+65
View File
@@ -0,0 +1,65 @@
package setting
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAzureSettings(t *testing.T) {
t.Run("cloud name", func(t *testing.T) {
testCases := []struct {
name string
configuredValue string
resolvedValue string
}{
{
name: "should be Public if not set",
configuredValue: "",
resolvedValue: AzurePublic,
},
{
name: "should be Public if set to Public",
configuredValue: AzurePublic,
resolvedValue: AzurePublic,
},
{
name: "should be Public if set to Public using alternative name",
configuredValue: "AzurePublicCloud",
resolvedValue: AzurePublic,
},
{
name: "should be China if set to China",
configuredValue: AzureChina,
resolvedValue: AzureChina,
},
{
name: "should be US Government if set to US Government using alternative name",
configuredValue: "usgov",
resolvedValue: AzureUSGovernment,
},
{
name: "should be same as set if not known",
configuredValue: "Custom123",
resolvedValue: "Custom123",
},
}
for _, c := range testCases {
t.Run(c.name, func(t *testing.T) {
cfg := NewCfg()
azureSection, err := cfg.Raw.NewSection("azure")
require.NoError(t, err)
_, err = azureSection.NewKey("cloud", c.configuredValue)
require.NoError(t, err)
cfg.readAzureSettings()
require.NotNil(t, cfg.Azure)
assert.Equal(t, c.resolvedValue, cfg.Azure.Cloud)
})
}
})
}
@@ -0,0 +1,156 @@
package alerting
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/models"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/tests/testinfra"
)
func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{"ngalert"},
DisableAnonymous: true,
NGAlertAdminConfigIntervalSeconds: 2,
})
s := testinfra.SetUpDatabase(t, dir)
// override bus to get the GetSignedInUserQuery handler
s.Bus = bus.GetBus()
grafanaListedAddr := testinfra.StartGrafana(t, dir, path, s)
// Create a user to make authenticated requests
createUser(t, s, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_ADMIN),
Login: "grafana",
Password: "password",
})
// Create a couple of "fake" Alertmanagers
fakeAM1 := schedule.NewFakeExternalAlertmanager(t)
fakeAM2 := schedule.NewFakeExternalAlertmanager(t)
// Now, let's test the configuration API.
{
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/admin_config", grafanaListedAddr)
resp := getRequest(t, alertsURL, http.StatusNotFound) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, string(b), "{\"message\": \"no admin configuration available\"}")
}
// Now, lets re-set external Alertmanagers.
{
ac := apimodels.PostableNGalertConfig{
Alertmanagers: []string{fakeAM1.URL(), fakeAM2.URL()},
}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(&ac)
require.NoError(t, err)
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/admin_config", grafanaListedAddr)
resp := postRequest(t, alertsURL, buf.String(), http.StatusCreated) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, string(b), "{\"message\": \"admin configuration updated\"}")
}
// If we get the configuration again, it shows us what we've set.
{
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/admin_config", grafanaListedAddr)
resp := getRequest(t, alertsURL, http.StatusOK) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, string(b), fmt.Sprintf("{\"alertmanagers\":[\"%s\",\"%s\"]}\n", fakeAM1.URL(), fakeAM2.URL()))
}
// With the configuration set, we should eventually discover those Alertmanagers set.
{
alertsURL := fmt.Sprintf("http://grafana:password@%s/api/v1/ngalert/alertmanagers", grafanaListedAddr)
require.Eventually(t, func() bool {
resp := getRequest(t, alertsURL, http.StatusOK) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
var alertmanagers apimodels.GettableAlertmanagers
require.NoError(t, json.Unmarshal(b, &alertmanagers))
return len(alertmanagers.Data.Active) == 2
}, 80*time.Second, 4*time.Second)
}
// Now, let's set an alert that should fire as quickly as possible.
{
// create the namespace we'll save our alerts to
_, err := createFolder(t, s, 0, "default")
require.NoError(t, err)
interval, err := model.ParseDuration("10s")
require.NoError(t, err)
rules := apimodels.PostableRuleGroupConfig{
Name: "arulegroup",
Interval: interval,
Rules: []apimodels.PostableExtendedRuleNode{
{
ApiRuleNode: &apimodels.ApiRuleNode{
For: interval,
Labels: map[string]string{"label1": "val1"},
Annotations: map[string]string{"annotation1": "val1"},
},
// this rule does not explicitly set no data and error states
// therefore it should get the default values
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: "AlwaysFiring",
Condition: "A",
Data: []ngmodels.AlertQuery{
{
RefID: "A",
RelativeTimeRange: ngmodels.RelativeTimeRange{
From: ngmodels.Duration(time.Duration(5) * time.Hour),
To: ngmodels.Duration(time.Duration(3) * time.Hour),
},
DatasourceUID: "-100",
Model: json.RawMessage(`{
"type": "math",
"expression": "2 + 3 > 1"
}`),
},
},
},
},
},
}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err = enc.Encode(&rules)
require.NoError(t, err)
ruleURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr)
// nolint
_ = postRequest(t, ruleURL, buf.String(), http.StatusAccepted)
}
//Eventually, our Alertmanagers should receiver the alert.
{
require.Eventually(t, func() bool {
return fakeAM1.AlertsCount() == 1 && fakeAM2.AlertsCount() == 1
}, 60*time.Second, 5*time.Second)
}
}
@@ -4,8 +4,10 @@ import (
"encoding/json"
"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
+71 -23
View File
@@ -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.
+10 -2
View File
@@ -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")
+10 -2
View File
@@ -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)
+1 -1
View File
@@ -56,10 +56,10 @@ func postRequest(t *testing.T, url string, body string, expStatusCode int) *http
buf := bytes.NewReader([]byte(body))
// 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)
+16 -8
View File
@@ -224,6 +224,13 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
_, err = featureSection.NewKey("enable", strings.Join(o.EnableFeatureToggles, " "))
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)
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ var metricsMap = map[string][]string{
"AWS/DAX": {"CPUUtilization", "NetworkPacketsIn", "NetworkPacketsOut", "GetItemRequestCount", "BatchGetItemRequestCount", "BatchWriteItemRequestCount", "DeleteItemRequestCount", "PutItemRequestCount", "UpdateItemRequestCount", "TransactWriteItemsCount", "TransactGetItemsCount", "ItemCacheHits", "ItemCacheMisses", "QueryCacheHits", "QueryCacheMisses", "ScanCacheHits", "ScanCacheMisses", "TotalRequestCount", "ErrorRequestCount", "FaultRequestCount", "FailedRequestCount", "QueryRequestCount", "ScanRequestCount", "ClientConnections", "EstimatedDbSize", "EvictedSize"},
"AWS/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"},
+8
View File
@@ -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))
}
+69 -38
View File
@@ -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)
}
+20 -4
View File
@@ -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
}
+22 -9
View File
@@ -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={() => {
@@ -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