Merge branch 'develop' into panel-edit-in-react

This commit is contained in:
Torkel Ödegaard 2018-11-20 09:51:06 +01:00
commit 7f46b75330
117 changed files with 3650 additions and 1029 deletions

View File

@ -1,7 +1,7 @@
[run] [run]
init_cmds = [ init_cmds = [
["go", "run", "build.go", "-dev", "build-server"], ["go", "run", "build.go", "-dev", "build-server"],
["./bin/grafana-server", "cfg:app_mode=development"] ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"]
] ]
watch_all = true watch_all = true
follow_symlinks = true follow_symlinks = true
@ -14,5 +14,5 @@ watch_exts = [".go", ".ini", ".toml", ".template.html"]
build_delay = 1500 build_delay = 1500
cmds = [ cmds = [
["go", "run", "build.go", "-dev", "build-server"], ["go", "run", "build.go", "-dev", "build-server"],
["./bin/grafana-server", "cfg:app_mode=development"] ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"]
] ]

View File

@ -127,7 +127,7 @@ jobs:
build-all: build-all:
docker: docker:
- image: grafana/build-container:1.2.0 - image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -175,7 +175,7 @@ jobs:
build: build:
docker: docker:
- image: grafana/build-container:1.2.0 - image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -241,7 +241,7 @@ jobs:
build-enterprise: build-enterprise:
docker: docker:
- image: grafana/build-container:1.2.0 - image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -273,7 +273,7 @@ jobs:
build-all-enterprise: build-all-enterprise:
docker: docker:
- image: grafana/build-container:1.2.0 - image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -359,6 +359,9 @@ jobs:
- run: - run:
name: deploy to gcp name: deploy to gcp
command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/release' command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/release'
- run:
name: Deploy to Grafana.com
command: './scripts/build/publish.sh --enterprise'
deploy-master: deploy-master:
docker: docker:

View File

@ -2,6 +2,7 @@
### New Features ### New Features
* **Alerting**: Introduce alert debouncing with the `FOR` setting. [#7886](https://github.com/grafana/grafana/issues/7886) & [#6202](https://github.com/grafana/grafana/issues/6202)
* **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat) * **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat)
* **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset) * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset)
* **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm) * **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm)
@ -9,30 +10,38 @@
* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro) * **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
* **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669) * **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669)
* **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550) * **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550)
* **Graph**: Time regions support enabling highlight of weekdays and/or certain timespans [#5930](https://github.com/grafana/grafana/issues/5930)
* **OAuth**: Automatic redirect to sign-in with OAuth [#11893](https://github.com/grafana/grafana/issues/11893), thx [@Nick-Triller](https://github.com/Nick-Triller)
### Minor ### Minor
* **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043)
* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
* **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy) * **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy)
* **Cloudwatch**: Enable using variables in the stats field [#13810](https://github.com/grafana/grafana/issues/13810), thx [@mtanda](https://github.com/mtanda)
* **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm) * **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm)
* **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367) * **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367)
* **Elasticsearch**: Fix deprecation warning about terms aggregation order key in Elasticsearch 6.x [#11977](https://github.com/grafana/grafana/issues/11977) * **Elasticsearch**: Fix deprecation warning about terms aggregation order key in Elasticsearch 6.x [#11977](https://github.com/grafana/grafana/issues/11977)
* **Graph**: Render dots when no connecting line can be made [#13605](https://github.com/grafana/grafana/issues/13605), thx [@jsferrei](https://github.com/jsferrei)
* **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme) * **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme)
* **Singlestat**: Fix XSS in prefix/postfix [#13946](https://github.com/grafana/grafana/issues/13946), thx [@cinaglia](https://github.com/cinaglia)
* **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
* **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945) * **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945)
* **Alerting**: More options for the Slack Alert notifier [#13993](https://github.com/grafana/grafana/issues/13993), thx [@andreykaipov](https://github.com/andreykaipov) * **Alerting**: More options for the Slack Alert notifier [#13993](https://github.com/grafana/grafana/issues/13993), thx [@andreykaipov](https://github.com/andreykaipov)
* **Alerting**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) * **Alerting**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
* **Alerting**: Increase Telegram captions length limit [#13876](https://github.com/grafana/grafana/pull/13876), thx [@skgsergio](https://github.com/skgsergio)
* **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876) * **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876)
* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
* **OAuth**: Fix Google OAuth relies on email, not google account id [#13924](https://github.com/grafana/grafana/issues/13924), thx [@vinicyusmacedo](https://github.com/vinicyusmacedo)
* **Dashboard**: Toggle legend using keyboard shortcut [#13655](https://github.com/grafana/grafana/issues/13655), thx [@davewat](https://github.com/davewat)
* **Dashboard**: Fix render dashboard row drag handle only in edit mode [#13555](https://github.com/grafana/grafana/issues/13555), thx [@praveensastry](https://github.com/praveensastry)
* **Teams**: Fix cannot select team if not included in initial search [#13425](https://github.com/grafana/grafana/issues/13425)
* **Render**: Support full height screenshots using phantomjs render script [#13352](https://github.com/grafana/grafana/pull/13352), thx [@amuraru](https://github.com/amuraru)
### Breaking changes ### Breaking changes
* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited) * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
# 5.3.5 (unreleased)
* **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043)
# 5.3.4 (2018-11-13) # 5.3.4 (2018-11-13)
* **Alerting**: Delete alerts when parent folder was deleted [#13322](https://github.com/grafana/grafana/issues/13322) * **Alerting**: Delete alerts when parent folder was deleted [#13322](https://github.com/grafana/grafana/issues/13322)

View File

@ -25,7 +25,7 @@ build: build-go build-js
build-docker-dev: build-docker-dev:
@echo "\033[92mInfo:\033[0m the frontend code is expected to be built already." @echo "\033[92mInfo:\033[0m the frontend code is expected to be built already."
go run build.go -goos linux -pkg-arch amd64 ${OPT} build package-only latest go run build.go -goos linux -pkg-arch amd64 ${OPT} build pkg-archive latest
cp dist/grafana-latest.linux-x64.tar.gz packaging/docker cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
cd packaging/docker && docker build --tag grafana/grafana:dev . cd packaging/docker && docker build --tag grafana/grafana:dev .

View File

@ -128,6 +128,8 @@ func main() {
if goos == linux { if goos == linux {
createLinuxPackages() createLinuxPackages()
} }
case "pkg-archive":
grunt(gruntBuildArg("package")...)
case "pkg-rpm": case "pkg-rpm":
grunt(gruntBuildArg("release")...) grunt(gruntBuildArg("release")...)

View File

@ -0,0 +1,511 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"links": [],
"panels": [
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 0
},
"id": 2,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "gray",
"fill": true,
"fillColor": "rgba(255, 255, 255, 0.03)",
"from": "08:30",
"fromDayOfWeek": 1,
"line": false,
"lineColor": "rgba(255, 255, 255, 0.2)",
"op": "time",
"to": "16:45",
"toDayOfWeek": 5
}
],
"timeShift": null,
"title": "Business Hours",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 8
},
"id": 4,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "",
"format": "time_series",
"intervalFactor": 1,
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "red",
"fill": true,
"fillColor": "rgba(255, 255, 255, 0.03)",
"from": "20:00",
"fromDayOfWeek": 7,
"line": false,
"lineColor": "rgba(255, 255, 255, 0.2)",
"op": "time",
"to": "23:00",
"toDayOfWeek": 7
}
],
"timeShift": null,
"title": "Sunday's 20-23",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {
"A-series": "#d683ce"
},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 3,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 0.5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(255, 0, 0, 0.22)",
"from": "",
"fromDayOfWeek": 1,
"line": true,
"lineColor": "rgba(255, 0, 0, 0.32)",
"op": "time",
"to": "",
"toDayOfWeek": 1
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(255, 127, 0, 0.22)",
"fromDayOfWeek": 2,
"line": true,
"lineColor": "rgba(255, 127, 0, 0.32)",
"op": "time",
"toDayOfWeek": 2
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(255, 255, 0, 0.22)",
"fromDayOfWeek": 3,
"line": true,
"lineColor": "rgba(255, 255, 0, 0.22)",
"op": "time",
"toDayOfWeek": 3
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(0, 255, 0, 0.22)",
"fromDayOfWeek": 4,
"line": true,
"lineColor": "rgba(0, 255, 0, 0.32)",
"op": "time",
"toDayOfWeek": 4
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(0, 0, 255, 0.22)",
"fromDayOfWeek": 5,
"line": true,
"lineColor": "rgba(0, 0, 255, 0.32)",
"op": "time",
"toDayOfWeek": 5
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(75, 0, 130, 0.22)",
"fromDayOfWeek": 6,
"line": true,
"lineColor": "rgba(75, 0, 130, 0.32)",
"op": "time",
"toDayOfWeek": 6
},
{
"colorMode": "custom",
"fill": true,
"fillColor": "rgba(148, 0, 211, 0.22)",
"fromDayOfWeek": 7,
"line": true,
"lineColor": "rgba(148, 0, 211, 0.32)",
"op": "time",
"toDayOfWeek": 7
}
],
"timeShift": null,
"title": "Each day of week",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"fill": 2,
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 24
},
"id": 5,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "",
"format": "time_series",
"intervalFactor": 1,
"refId": "A",
"scenarioId": "random_walk",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [
{
"colorMode": "red",
"fill": false,
"from": "05:00",
"line": true,
"op": "time"
}
],
"timeShift": null,
"title": "05:00",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"refresh": false,
"schemaVersion": 16,
"style": "dark",
"tags": [
"gdev",
"panel-tests"
],
"templating": {
"list": []
},
"time": {
"from": "now-30d",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"timezone": "browser",
"title": "Panel Tests - Graph (Time Regions)",
"uid": "XMjIZPmik",
"version": 1
}

View File

@ -1,19 +1,21 @@
{ {
"revision": 2, "annotations": {
"title": "Alerting with TestData", "list": [
"tags": [
"grafana-test"
],
"style": "dark",
"timezone": "browser",
"editable": true,
"hideControls": false,
"sharedCrosshair": false,
"rows": [
{ {
"collapse": false, "builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true, "editable": true,
"height": 255.625, "gnetId": null,
"graphTooltip": 0,
"links": [],
"panels": [ "panels": [
{ {
"alert": { "alert": {
@ -48,10 +50,18 @@
}, },
"aliasColors": {}, "aliasColors": {},
"bars": false, "bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata", "datasource": "gdev-testdata",
"editable": true, "editable": true,
"error": false, "error": false,
"fill": 1, "fill": 1,
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 0
},
"id": 3, "id": 3,
"isNew": true, "isNew": true,
"legend": { "legend": {
@ -72,7 +82,7 @@
"points": false, "points": false,
"renderer": "flot", "renderer": "flot",
"seriesOverrides": [], "seriesOverrides": [],
"span": 6, "spaceLength": 10,
"stack": false, "stack": false,
"steppedLine": false, "steppedLine": false,
"targets": [ "targets": [
@ -86,11 +96,11 @@
], ],
"thresholds": [ "thresholds": [
{ {
"value": 60, "colorMode": "critical",
"op": "gt",
"fill": true, "fill": true,
"line": true, "line": true,
"colorMode": "critical" "op": "gt",
"value": 60
} }
], ],
"timeFrom": null, "timeFrom": null,
@ -104,6 +114,7 @@
}, },
"type": "graph", "type": "graph",
"xaxis": { "xaxis": {
"buckets": null,
"mode": "time", "mode": "time",
"name": null, "name": null,
"show": true, "show": true,
@ -126,7 +137,11 @@
"min": null, "min": null,
"show": true "show": true
} }
] ],
"yaxis": {
"align": false,
"alignLevel": null
}
}, },
{ {
"alert": { "alert": {
@ -153,6 +168,8 @@
} }
], ],
"enabled": true, "enabled": true,
"executionErrorState": "alerting",
"for": "0m",
"frequency": "60s", "frequency": "60s",
"handler": 1, "handler": 1,
"name": "TestData - Always Alerting", "name": "TestData - Always Alerting",
@ -161,10 +178,18 @@
}, },
"aliasColors": {}, "aliasColors": {},
"bars": false, "bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata", "datasource": "gdev-testdata",
"editable": true, "editable": true,
"error": false, "error": false,
"fill": 1, "fill": 1,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 0
},
"id": 4, "id": 4,
"isNew": true, "isNew": true,
"legend": { "legend": {
@ -185,7 +210,7 @@
"points": false, "points": false,
"renderer": "flot", "renderer": "flot",
"seriesOverrides": [], "seriesOverrides": [],
"span": 6, "spaceLength": 10,
"stack": false, "stack": false,
"steppedLine": false, "steppedLine": false,
"targets": [ "targets": [
@ -217,6 +242,7 @@
}, },
"type": "graph", "type": "graph",
"xaxis": { "xaxis": {
"buckets": null,
"mode": "time", "mode": "time",
"name": null, "name": null,
"show": true, "show": true,
@ -239,12 +265,282 @@
"min": null, "min": null,
"show": true "show": true
} }
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
1
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"15m",
"now"
] ]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
} }
], ],
"title": "New row" "executionErrorState": "alerting",
"for": "5m",
"frequency": "1m",
"handler": 1,
"name": "TestData - No data",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"editable": true,
"error": false,
"fill": 1,
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 7
},
"id": 5,
"isNew": true,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "connected",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenario": "random_walk",
"scenarioId": "no_data_points",
"stringInput": "",
"target": ""
} }
], ],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 1
}
],
"timeFrom": null,
"timeShift": null,
"title": "No data",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": "",
"logBase": 1,
"max": null,
"min": "0",
"show": true
},
{
"format": "short",
"label": "",
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
177
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"15m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"executionErrorState": "alerting",
"for": "1m",
"frequency": "1m",
"handler": 1,
"name": "TestData - Always Alerting with For",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"editable": true,
"error": false,
"fill": 1,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 7
},
"id": 6,
"isNew": true,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "connected",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenario": "random_walk",
"scenarioId": "csv_metric_values",
"stringInput": "200,445,100,150,200,220,190",
"target": ""
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 177
}
],
"timeFrom": null,
"timeShift": null,
"title": "Always Alerting with For",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": "",
"logBase": 1,
"max": null,
"min": "0",
"show": true
},
{
"format": "short",
"label": "",
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"revision": 2,
"schemaVersion": 16,
"style": "dark",
"tags": [
"grafana-test"
],
"templating": {
"list": []
},
"time": { "time": {
"from": "now-6h", "from": "now-6h",
"to": "now" "to": "now"
@ -274,14 +570,8 @@
"30d" "30d"
] ]
}, },
"templating": { "timezone": "browser",
"list": [] "title": "Alerting with TestData",
}, "uid": "7MeksYbmk",
"annotations": { "version": 1
"list": []
},
"schemaVersion": 13,
"version": 4,
"links": [],
"gnetId": null
} }

View File

@ -9,7 +9,7 @@ services:
- /var/run/docker.sock:/tmp/docker.sock:ro - /var/run/docker.sock:/tmp/docker.sock:ro
db: db:
image: mysql image: mysql:5.6
environment: environment:
MYSQL_ROOT_PASSWORD: rootpass MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: grafana MYSQL_DATABASE: grafana

View File

@ -39,6 +39,7 @@ local alertDashboardTemplate = {
"executionErrorState": "alerting", "executionErrorState": "alerting",
"frequency": "10s", "frequency": "10s",
"handler": 1, "handler": 1,
"for": "1m",
"name": "bulk alerting", "name": "bulk alerting",
"noDataState": "no_data", "noDataState": "no_data",
"notifications": [ "notifications": [

View File

@ -39,7 +39,7 @@ Currently alerting supports a limited form of high availability. Since v4.2.0 of
## Rule Config ## Rule Config
{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}}
Currently only the graph panel supports alert rules but this will be added to the **Singlestat** and **Table** Currently only the graph panel supports alert rules but this will be added to the **Singlestat** and **Table**
panels as well in a future release. panels as well in a future release.
@ -48,6 +48,16 @@ panels as well in a future release.
Here you can specify the name of the alert rule and how often the scheduler should evaluate the alert rule. Here you can specify the name of the alert rule and how often the scheduler should evaluate the alert rule.
### For
> This setting is available in Grafana 5.4 and above.
If an alert rule has a configured `For` and the query violates the configured threshold it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications.
Typically, it's always a good idea to use this setting since its often worse to get false positive than wait a few minutes before the alert notification triggers.
{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}}
### Conditions ### Conditions
Currently the only condition type that exists is a `Query` condition that allows you to Currently the only condition type that exists is a `Query` condition that allows you to
@ -57,11 +67,11 @@ specify a query letter, time range and an aggregation function.
### Query condition example ### Query condition example
```sql ```sql
avg() OF query(A, 5m, now) IS BELOW 14 avg() OF query(A, 15m, now) IS BELOW 14
``` ```
- `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function. - `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function.
- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data. - `query(A, 15m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `15m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data.
- `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold. - `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold.
The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially. The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.

View File

@ -73,7 +73,18 @@ You can hide the Grafana login form using the below configuration settings.
```bash ```bash
[auth] [auth]
disable_login_form ⁼ true disable_login_form = true
```
### Automatic OAuth login
Set to true to attempt login with OAuth automatically, skipping the login screen.
This setting is ignored if multiple OAuth providers are configured.
Defaults to `false`.
```bash
[auth]
oauth_auto_login = true
``` ```
### Hide sign-out menu ### Hide sign-out menu

View File

@ -186,6 +186,14 @@ There is an option under Series overrides to draw lines as dashes. Set Dashes to
Thresholds allow you to add arbitrary lines or sections to the graph to make it easier to see when Thresholds allow you to add arbitrary lines or sections to the graph to make it easier to see when
the graph crosses a particular threshold. the graph crosses a particular threshold.
### Time Regions
> Only available in Grafana v5.4 and above.
{{< docs-imagebox img="/img/docs/v54/graph_time_regions.png" max-width= "800px" >}}
Time regions allow you to highlight certain time regions of the graph to make it easier to see for example weekends, business hours and/or off work hours.
## Time Range ## Time Range
{{< docs-imagebox img="/img/docs/v51/graph-time-range.png" max-width= "900px" >}} {{< docs-imagebox img="/img/docs/v51/graph-time-range.png" max-width= "900px" >}}

View File

@ -56,7 +56,7 @@ if [ -f "$DEFAULT" ]; then
. "$DEFAULT" . "$DEFAULT"
fi fi
DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=deb cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}"
function checkUser() { function checkUser() {
if [ `id -u` -ne 0 ]; then if [ `id -u` -ne 0 ]; then

View File

@ -17,6 +17,7 @@ RuntimeDirectoryMode=0750
ExecStart=/usr/sbin/grafana-server \ ExecStart=/usr/sbin/grafana-server \
--config=${CONF_FILE} \ --config=${CONF_FILE} \
--pidfile=${PID_FILE_DIR}/grafana-server.pid \ --pidfile=${PID_FILE_DIR}/grafana-server.pid \
--packaging=deb \
cfg:default.paths.logs=${LOG_DIR} \ cfg:default.paths.logs=${LOG_DIR} \
cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.data=${DATA_DIR} \
cfg:default.paths.plugins=${PLUGINS_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} \

View File

@ -80,6 +80,7 @@ fi
exec grafana-server \ exec grafana-server \
--homepath="$GF_PATHS_HOME" \ --homepath="$GF_PATHS_HOME" \
--config="$GF_PATHS_CONFIG" \ --config="$GF_PATHS_CONFIG" \
--packaging=docker \
"$@" \ "$@" \
cfg:default.log.mode="console" \ cfg:default.log.mode="console" \
cfg:default.paths.data="$GF_PATHS_DATA" \ cfg:default.paths.data="$GF_PATHS_DATA" \

View File

@ -60,7 +60,7 @@ fi
# overwrite settings from default file # overwrite settings from default file
[ -e /etc/sysconfig/$NAME ] && . /etc/sysconfig/$NAME [ -e /etc/sysconfig/$NAME ] && . /etc/sysconfig/$NAME
DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=rpm cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}"
function isRunning() { function isRunning() {
status -p $PID_FILE $NAME > /dev/null 2>&1 status -p $PID_FILE $NAME > /dev/null 2>&1

View File

@ -17,6 +17,7 @@ RuntimeDirectoryMode=0750
ExecStart=/usr/sbin/grafana-server \ ExecStart=/usr/sbin/grafana-server \
--config=${CONF_FILE} \ --config=${CONF_FILE} \
--pidfile=${PID_FILE_DIR}/grafana-server.pid \ --pidfile=${PID_FILE_DIR}/grafana-server.pid \
--packaging=rpm \
cfg:default.paths.logs=${LOG_DIR} \ cfg:default.paths.logs=${LOG_DIR} \
cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.data=${DATA_DIR} \
cfg:default.paths.plugins=${PLUGINS_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} \

View File

@ -295,7 +295,7 @@ func PauseAlert(c *m.ReqContext, dto dtos.PauseAlertCommand) Response {
return Error(500, "", err) return Error(500, "", err)
} }
var response m.AlertStateType = m.AlertStatePending var response m.AlertStateType = m.AlertStateUnknown
pausedState := "un-paused" pausedState := "un-paused"
if cmd.Paused { if cmd.Paused {
response = m.AlertStatePaused response = m.AlertStatePaused

View File

@ -39,6 +39,10 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
viewData.Settings["loginError"] = loginError viewData.Settings["loginError"] = loginError
} }
if tryOAuthAutoLogin(c) {
return
}
if !tryLoginUsingRememberCookie(c) { if !tryLoginUsingRememberCookie(c) {
c.HTML(200, ViewIndex, viewData) c.HTML(200, ViewIndex, viewData)
return return
@ -53,6 +57,24 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
c.Redirect(setting.AppSubUrl + "/") c.Redirect(setting.AppSubUrl + "/")
} }
func tryOAuthAutoLogin(c *m.ReqContext) bool {
if !setting.OAuthAutoLogin {
return false
}
oauthInfos := setting.OAuthService.OAuthInfos
if len(oauthInfos) != 1 {
log.Warn("Skipping OAuth auto login because multiple OAuth providers are configured.")
return false
}
for key := range setting.OAuthService.OAuthInfos {
redirectUrl := setting.AppSubUrl + "/login/" + key
log.Info("OAuth auto login enabled. Redirecting to " + redirectUrl)
c.Redirect(redirectUrl, 307)
return true
}
return false
}
func tryLoginUsingRememberCookie(c *m.ReqContext) bool { func tryLoginUsingRememberCookie(c *m.ReqContext) bool {
// Check auto-login. // Check auto-login.
uname := c.GetCookie(setting.CookieUserName) uname := c.GetCookie(setting.CookieUserName)

View File

@ -13,7 +13,7 @@ import (
"syscall" "syscall"
"time" "time"
extensions "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/metrics"
_ "github.com/grafana/grafana/pkg/services/alerting/conditions" _ "github.com/grafana/grafana/pkg/services/alerting/conditions"
@ -39,6 +39,7 @@ var buildstamp string
var configFile = flag.String("config", "", "path to config file") var configFile = flag.String("config", "", "path to config file")
var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory") var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory")
var pidFile = flag.String("pidfile", "", "path to pid file") var pidFile = flag.String("pidfile", "", "path to pid file")
var packaging = flag.String("packaging", "unknown", "describes the way Grafana was installed")
func main() { func main() {
v := flag.Bool("v", false, "prints current version and exits") v := flag.Bool("v", false, "prints current version and exits")
@ -79,6 +80,7 @@ func main() {
setting.BuildStamp = buildstampInt64 setting.BuildStamp = buildstampInt64
setting.BuildBranch = buildBranch setting.BuildBranch = buildBranch
setting.IsEnterprise = extensions.IsEnterprise setting.IsEnterprise = extensions.IsEnterprise
setting.Packaging = validPackaging(*packaging)
metrics.SetBuildInformation(version, commit, buildBranch) metrics.SetBuildInformation(version, commit, buildBranch)
@ -95,6 +97,16 @@ func main() {
os.Exit(code) os.Exit(code)
} }
func validPackaging(packaging string) string {
validTypes := []string{"dev", "deb", "rpm", "docker", "brew", "hosted", "unknown"}
for _, vt := range validTypes {
if packaging == vt {
return packaging
}
}
return "unknown"
}
func listenToSystemSignals(server *GrafanaServerImpl) { func listenToSystemSignals(server *GrafanaServerImpl) {
signalChan := make(chan os.Signal, 1) signalChan := make(chan os.Signal, 1)
sighupChan := make(chan os.Signal, 1) sighupChan := make(chan os.Signal, 1)

View File

@ -313,7 +313,7 @@ func init() {
// SetBuildInformation sets the build information for this binary // SetBuildInformation sets the build information for this binary
func SetBuildInformation(version, revision, branch string) { func SetBuildInformation(version, revision, branch string) {
// We export this info twice for backwards compability. // We export this info twice for backwards compatibility.
// Once this have been released for some time we should be able to remote `M_Grafana_Version` // Once this have been released for some time we should be able to remote `M_Grafana_Version`
// The reason we added a new one is that its common practice in the prometheus community // The reason we added a new one is that its common practice in the prometheus community
// to name this metric `*_build_info` so its easy to do aggregation on all programs. // to name this metric `*_build_info` so its easy to do aggregation on all programs.
@ -402,6 +402,7 @@ func sendUsageStats(oauthProviders map[string]bool) {
"os": runtime.GOOS, "os": runtime.GOOS,
"arch": runtime.GOARCH, "arch": runtime.GOARCH,
"edition": getEdition(), "edition": getEdition(),
"packaging": setting.Packaging,
} }
statsQuery := models.GetSystemStatsQuery{} statsQuery := models.GetSystemStatsQuery{}
@ -447,6 +448,8 @@ func sendUsageStats(oauthProviders map[string]bool) {
} }
metrics["stats.ds.other.count"] = dsOtherCount metrics["stats.ds.other.count"] = dsOtherCount
metrics["stats.packaging."+setting.Packaging+".count"] = 1
dsAccessStats := models.GetDataSourceAccessStatsQuery{} dsAccessStats := models.GetDataSourceAccessStatsQuery{}
if err := bus.Dispatch(&dsAccessStats); err != nil { if err := bus.Dispatch(&dsAccessStats); err != nil {
metricsLogger.Error("Failed to get datasource access stats", "error", err) metricsLogger.Error("Failed to get datasource access stats", "error", err)

View File

@ -176,6 +176,7 @@ func TestMetrics(t *testing.T) {
setting.BasicAuthEnabled = true setting.BasicAuthEnabled = true
setting.LdapEnabled = true setting.LdapEnabled = true
setting.AuthProxyEnabled = true setting.AuthProxyEnabled = true
setting.Packaging = "deb"
wg.Add(1) wg.Add(1)
sendUsageStats(oauthProviders) sendUsageStats(oauthProviders)
@ -243,6 +244,8 @@ func TestMetrics(t *testing.T) {
So(metrics.Get("stats.auth_enabled.oauth_google.count").MustInt(), ShouldEqual, 1) So(metrics.Get("stats.auth_enabled.oauth_google.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.auth_enabled.oauth_generic_oauth.count").MustInt(), ShouldEqual, 1) So(metrics.Get("stats.auth_enabled.oauth_generic_oauth.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt(), ShouldEqual, 1) So(metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.packaging.deb.count").MustInt(), ShouldEqual, 1)
}) })
}) })

View File

@ -19,6 +19,7 @@ const (
AlertStateAlerting AlertStateType = "alerting" AlertStateAlerting AlertStateType = "alerting"
AlertStateOK AlertStateType = "ok" AlertStateOK AlertStateType = "ok"
AlertStatePending AlertStateType = "pending" AlertStatePending AlertStateType = "pending"
AlertStateUnknown AlertStateType = "unknown"
) )
const ( const (
@ -39,7 +40,12 @@ var (
) )
func (s AlertStateType) IsValid() bool { func (s AlertStateType) IsValid() bool {
return s == AlertStateOK || s == AlertStateNoData || s == AlertStatePaused || s == AlertStatePending return s == AlertStateOK ||
s == AlertStateNoData ||
s == AlertStatePaused ||
s == AlertStatePending ||
s == AlertStateAlerting ||
s == AlertStateUnknown
} }
func (s NoDataOption) IsValid() bool { func (s NoDataOption) IsValid() bool {
@ -66,12 +72,13 @@ type Alert struct {
PanelId int64 PanelId int64
Name string Name string
Message string Message string
Severity string Severity string //Unused
State AlertStateType State AlertStateType
Handler int64 Handler int64 //Unused
Silenced bool Silenced bool
ExecutionError string ExecutionError string
Frequency int64 Frequency int64
For time.Duration
EvalData *simplejson.Json EvalData *simplejson.Json
NewStateDate time.Time NewStateDate time.Time

View File

@ -68,8 +68,13 @@ func (c *EvalContext) GetStateModel() *StateDescription {
Color: "#D63232", Color: "#D63232",
Text: "Alerting", Text: "Alerting",
} }
case m.AlertStateUnknown:
return &StateDescription{
Color: "#888888",
Text: "Unknown",
}
default: default:
panic("Unknown rule state " + c.Rule.State) panic("Unknown rule state for alert " + c.Rule.State)
} }
} }
@ -113,7 +118,26 @@ func (c *EvalContext) GetRuleUrl() (string, error) {
return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil
} }
// GetNewState returns the new state from the alert rule evaluation
func (c *EvalContext) GetNewState() m.AlertStateType { func (c *EvalContext) GetNewState() m.AlertStateType {
ns := getNewStateInternal(c)
if ns != m.AlertStateAlerting || c.Rule.For == 0 {
return ns
}
since := time.Since(c.Rule.LastStateChange)
if c.PrevAlertState == m.AlertStatePending && since > c.Rule.For {
return m.AlertStateAlerting
}
if c.PrevAlertState == m.AlertStateAlerting {
return m.AlertStateAlerting
}
return m.AlertStatePending
}
func getNewStateInternal(c *EvalContext) m.AlertStateType {
if c.Error != nil { if c.Error != nil {
c.log.Error("Alert Rule Result Error", c.log.Error("Alert Rule Result Error",
"ruleId", c.Rule.Id, "ruleId", c.Rule.Id,
@ -125,11 +149,13 @@ func (c *EvalContext) GetNewState() m.AlertStateType {
return c.PrevAlertState return c.PrevAlertState
} }
return c.Rule.ExecutionErrorState.ToAlertState() return c.Rule.ExecutionErrorState.ToAlertState()
}
} else if c.Firing { if c.Firing {
return m.AlertStateAlerting return m.AlertStateAlerting
}
} else if c.NoDataFound { if c.NoDataFound {
c.log.Info("Alert Rule returned no data", c.log.Info("Alert Rule returned no data",
"ruleId", c.Rule.Id, "ruleId", c.Rule.Id,
"name", c.Rule.Name, "name", c.Rule.Name,

View File

@ -2,11 +2,11 @@ package alerting
import ( import (
"context" "context"
"fmt" "errors"
"testing" "testing"
"time"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
) )
func TestStateIsUpdatedWhenNeeded(t *testing.T) { func TestStateIsUpdatedWhenNeeded(t *testing.T) {
@ -31,71 +31,176 @@ func TestStateIsUpdatedWhenNeeded(t *testing.T) {
}) })
} }
func TestAlertingEvalContext(t *testing.T) { func TestGetStateFromEvalContext(t *testing.T) {
Convey("Should compute and replace properly new rule state", t, func() { tcs := []struct {
name string
expected models.AlertStateType
applyFn func(ec *EvalContext)
}{
{
name: "ok -> alerting",
expected: models.AlertStateAlerting,
applyFn: func(ec *EvalContext) {
ec.Firing = true
ec.PrevAlertState = models.AlertStateOK
},
},
{
name: "ok -> error(alerting)",
expected: models.AlertStateAlerting,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateOK
ec.Error = errors.New("test error")
ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting
},
},
{
name: "ok -> pending. since its been firing for less than FOR",
expected: models.AlertStatePending,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateOK
ec.Firing = true
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2)
ec.Rule.For = time.Minute * 5
},
},
{
name: "ok -> pending. since it has to be pending longer than FOR and prev state is ok",
expected: models.AlertStatePending,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateOK
ec.Firing = true
ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5))
ec.Rule.For = time.Minute * 2
},
},
{
name: "pending -> alerting. since its been firing for more than FOR and prev state is pending",
expected: models.AlertStateAlerting,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStatePending
ec.Firing = true
ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5))
ec.Rule.For = time.Minute * 2
},
},
{
name: "alerting -> alerting. should not update regardless of FOR",
expected: models.AlertStateAlerting,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateAlerting
ec.Firing = true
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
ec.Rule.For = time.Minute * 2
},
},
{
name: "ok -> ok. should not update regardless of FOR",
expected: models.AlertStateOK,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateOK
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
ec.Rule.For = time.Minute * 2
},
},
{
name: "ok -> error(keep_last)",
expected: models.AlertStateOK,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateOK
ec.Error = errors.New("test error")
ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState
},
},
{
name: "pending -> error(keep_last)",
expected: models.AlertStatePending,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStatePending
ec.Error = errors.New("test error")
ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState
},
},
{
name: "ok -> no_data(alerting)",
expected: models.AlertStateAlerting,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateOK
ec.Rule.NoDataState = models.NoDataSetAlerting
ec.NoDataFound = true
},
},
{
name: "ok -> no_data(keep_last)",
expected: models.AlertStateOK,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStateOK
ec.Rule.NoDataState = models.NoDataKeepState
ec.NoDataFound = true
},
},
{
name: "pending -> no_data(keep_last)",
expected: models.AlertStatePending,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStatePending
ec.Rule.NoDataState = models.NoDataKeepState
ec.NoDataFound = true
},
},
{
name: "pending -> no_data(alerting) with for duration have not passed",
expected: models.AlertStatePending,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStatePending
ec.Rule.NoDataState = models.NoDataSetAlerting
ec.NoDataFound = true
ec.Rule.For = time.Minute * 5
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2)
},
},
{
name: "pending -> no_data(alerting) should set alerting since time passed FOR",
expected: models.AlertStateAlerting,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStatePending
ec.Rule.NoDataState = models.NoDataSetAlerting
ec.NoDataFound = true
ec.Rule.For = time.Minute * 2
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
},
},
{
name: "pending -> error(alerting) with for duration have not passed ",
expected: models.AlertStatePending,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStatePending
ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting
ec.Error = errors.New("test error")
ec.Rule.For = time.Minute * 5
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2)
},
},
{
name: "pending -> error(alerting) should set alerting since time passed FOR",
expected: models.AlertStateAlerting,
applyFn: func(ec *EvalContext) {
ec.PrevAlertState = models.AlertStatePending
ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting
ec.Error = errors.New("test error")
ec.Rule.For = time.Minute * 2
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
},
},
}
for _, tc := range tcs {
ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}})
dummieError := fmt.Errorf("dummie error")
Convey("ok -> alerting", func() { tc.applyFn(ctx)
ctx.PrevAlertState = models.AlertStateOK have := ctx.GetNewState()
ctx.Firing = true if have != tc.expected {
t.Errorf("failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, string(have))
ctx.Rule.State = ctx.GetNewState() }
So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) }
})
Convey("ok -> error(alerting)", func() {
ctx.PrevAlertState = models.AlertStateOK
ctx.Error = dummieError
ctx.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting
ctx.Rule.State = ctx.GetNewState()
So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting)
})
Convey("ok -> error(keep_last)", func() {
ctx.PrevAlertState = models.AlertStateOK
ctx.Error = dummieError
ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState
ctx.Rule.State = ctx.GetNewState()
So(ctx.Rule.State, ShouldEqual, models.AlertStateOK)
})
Convey("pending -> error(keep_last)", func() {
ctx.PrevAlertState = models.AlertStatePending
ctx.Error = dummieError
ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState
ctx.Rule.State = ctx.GetNewState()
So(ctx.Rule.State, ShouldEqual, models.AlertStatePending)
})
Convey("ok -> no_data(alerting)", func() {
ctx.PrevAlertState = models.AlertStateOK
ctx.Rule.NoDataState = models.NoDataSetAlerting
ctx.NoDataFound = true
ctx.Rule.State = ctx.GetNewState()
So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting)
})
Convey("ok -> no_data(keep_last)", func() {
ctx.PrevAlertState = models.AlertStateOK
ctx.Rule.NoDataState = models.NoDataKeepState
ctx.NoDataFound = true
ctx.Rule.State = ctx.GetNewState()
So(ctx.Rule.State, ShouldEqual, models.AlertStateOK)
})
Convey("pending -> no_data(keep_last)", func() {
ctx.PrevAlertState = models.AlertStatePending
ctx.Rule.NoDataState = models.NoDataKeepState
ctx.NoDataFound = true
ctx.Rule.State = ctx.GetNewState()
So(ctx.Rule.State, ShouldEqual, models.AlertStatePending)
})
})
} }

View File

@ -2,8 +2,8 @@ package alerting
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
@ -115,6 +115,15 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
return nil, ValidationError{Reason: "Could not parse frequency"} return nil, ValidationError{Reason: "Could not parse frequency"}
} }
rawFor := jsonAlert.Get("for").MustString()
var forValue time.Duration
if rawFor != "" {
forValue, err = time.ParseDuration(rawFor)
if err != nil {
return nil, ValidationError{Reason: "Could not parse for"}
}
}
alert := &m.Alert{ alert := &m.Alert{
DashboardId: e.Dash.Id, DashboardId: e.Dash.Id,
OrgId: e.OrgID, OrgId: e.OrgID,
@ -124,6 +133,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
Handler: jsonAlert.Get("handler").MustInt64(), Handler: jsonAlert.Get("handler").MustInt64(),
Message: jsonAlert.Get("message").MustString(), Message: jsonAlert.Get("message").MustString(),
Frequency: frequency, Frequency: frequency,
For: forValue,
} }
for _, condition := range jsonAlert.Get("conditions").MustArray() { for _, condition := range jsonAlert.Get("conditions").MustArray() {

View File

@ -3,6 +3,7 @@ package alerting
import ( import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"time"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
@ -46,7 +47,7 @@ func TestAlertRuleExtraction(t *testing.T) {
return nil return nil
}) })
json, err := ioutil.ReadFile("./test-data/graphite-alert.json") json, err := ioutil.ReadFile("./testdata/graphite-alert.json")
So(err, ShouldBeNil) So(err, ShouldBeNil)
Convey("Extractor should not modify the original json", func() { Convey("Extractor should not modify the original json", func() {
@ -118,6 +119,11 @@ func TestAlertRuleExtraction(t *testing.T) {
So(alerts[1].PanelId, ShouldEqual, 4) So(alerts[1].PanelId, ShouldEqual, 4)
}) })
Convey("should extract for param", func() {
So(alerts[0].For, ShouldEqual, time.Minute*2)
So(alerts[1].For, ShouldEqual, time.Duration(0))
})
Convey("should extract name and desc", func() { Convey("should extract name and desc", func() {
So(alerts[0].Name, ShouldEqual, "name1") So(alerts[0].Name, ShouldEqual, "name1")
So(alerts[0].Message, ShouldEqual, "desc1") So(alerts[0].Message, ShouldEqual, "desc1")
@ -140,7 +146,7 @@ func TestAlertRuleExtraction(t *testing.T) {
}) })
Convey("Panels missing id should return error", func() { Convey("Panels missing id should return error", func() {
panelWithoutId, err := ioutil.ReadFile("./test-data/panels-missing-id.json") panelWithoutId, err := ioutil.ReadFile("./testdata/panels-missing-id.json")
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(panelWithoutId) dashJson, err := simplejson.NewJson(panelWithoutId)
@ -156,7 +162,7 @@ func TestAlertRuleExtraction(t *testing.T) {
}) })
Convey("Panel with id set to zero should return error", func() { Convey("Panel with id set to zero should return error", func() {
panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json") panelWithIdZero, err := ioutil.ReadFile("./testdata/panel-with-id-0.json")
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(panelWithIdZero) dashJson, err := simplejson.NewJson(panelWithIdZero)
@ -172,7 +178,7 @@ func TestAlertRuleExtraction(t *testing.T) {
}) })
Convey("Parse alerts from dashboard without rows", func() { Convey("Parse alerts from dashboard without rows", func() {
json, err := ioutil.ReadFile("./test-data/v5-dashboard.json") json, err := ioutil.ReadFile("./testdata/v5-dashboard.json")
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(json) dashJson, err := simplejson.NewJson(json)
@ -192,7 +198,7 @@ func TestAlertRuleExtraction(t *testing.T) {
}) })
Convey("Parse and validate dashboard containing influxdb alert", func() { Convey("Parse and validate dashboard containing influxdb alert", func() {
json, err := ioutil.ReadFile("./test-data/influxdb-alert.json") json, err := ioutil.ReadFile("./testdata/influxdb-alert.json")
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(json) dashJson, err := simplejson.NewJson(json)
@ -221,7 +227,7 @@ func TestAlertRuleExtraction(t *testing.T) {
}) })
Convey("Should be able to extract collapsed panels", func() { Convey("Should be able to extract collapsed panels", func() {
json, err := ioutil.ReadFile("./test-data/collapsed-panels.json") json, err := ioutil.ReadFile("./testdata/collapsed-panels.json")
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(json) dashJson, err := simplejson.NewJson(json)
@ -242,7 +248,7 @@ func TestAlertRuleExtraction(t *testing.T) {
}) })
Convey("Parse and validate dashboard without id and containing an alert", func() { Convey("Parse and validate dashboard without id and containing an alert", func() {
json, err := ioutil.ReadFile("./test-data/dash-without-id.json") json, err := ioutil.ReadFile("./testdata/dash-without-id.json")
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJSON, err := simplejson.NewJson(json) dashJSON, err := simplejson.NewJson(json)

View File

@ -1,13 +1,60 @@
package notifiers package notifiers
import ( import (
"context"
"testing" "testing"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
func TestWhenAlertManagerShouldNotify(t *testing.T) {
tcs := []struct {
prevState m.AlertStateType
newState m.AlertStateType
expect bool
}{
{
prevState: m.AlertStatePending,
newState: m.AlertStateOK,
expect: false,
},
{
prevState: m.AlertStateAlerting,
newState: m.AlertStateOK,
expect: true,
},
{
prevState: m.AlertStateOK,
newState: m.AlertStatePending,
expect: false,
},
{
prevState: m.AlertStateUnknown,
newState: m.AlertStatePending,
expect: false,
},
}
for _, tc := range tcs {
am := &AlertmanagerNotifier{log: log.New("test.logger")}
evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
State: tc.prevState,
})
evalContext.Rule.State = tc.newState
res := am.ShouldNotify(context.TODO(), evalContext, &m.AlertNotificationState{})
if res != tc.expect {
t.Errorf("got %v expected %v", res, tc.expect)
}
}
}
func TestAlertmanagerNotifier(t *testing.T) { func TestAlertmanagerNotifier(t *testing.T) {
Convey("Alertmanager notifier tests", t, func() { Convey("Alertmanager notifier tests", t, func() {

View File

@ -67,6 +67,16 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalC
} }
// Do not notify when we become OK for the first time. // Do not notify when we become OK for the first time.
if context.PrevAlertState == models.AlertStateUnknown && context.Rule.State == models.AlertStateOK {
return false
}
// Do not notify when we become OK for the first time.
if context.PrevAlertState == models.AlertStateUnknown && context.Rule.State == models.AlertStatePending {
return false
}
// Do not notify when we become OK from pending
if context.PrevAlertState == models.AlertStatePending && context.Rule.State == models.AlertStateOK { if context.PrevAlertState == models.AlertStatePending && context.Rule.State == models.AlertStateOK {
return false return false
} }

View File

@ -29,7 +29,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK, newState: m.AlertStateOK,
prevState: m.AlertStatePending, prevState: m.AlertStatePending,
sendReminder: false, sendReminder: false,
state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
@ -38,7 +37,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateAlerting, newState: m.AlertStateAlerting,
prevState: m.AlertStateOK, prevState: m.AlertStateOK,
sendReminder: false, sendReminder: false,
state: &m.AlertNotificationState{},
expect: true, expect: true,
}, },
@ -47,7 +45,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStatePending, newState: m.AlertStatePending,
prevState: m.AlertStateOK, prevState: m.AlertStateOK,
sendReminder: false, sendReminder: false,
state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
@ -56,7 +53,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK, newState: m.AlertStateOK,
prevState: m.AlertStateOK, prevState: m.AlertStateOK,
sendReminder: false, sendReminder: false,
state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
@ -65,7 +61,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK, newState: m.AlertStateOK,
prevState: m.AlertStateOK, prevState: m.AlertStateOK,
sendReminder: true, sendReminder: true,
state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
@ -74,7 +69,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK, newState: m.AlertStateOK,
prevState: m.AlertStateAlerting, prevState: m.AlertStateAlerting,
sendReminder: false, sendReminder: false,
state: &m.AlertNotificationState{},
expect: true, expect: true,
}, },
@ -94,7 +88,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
prevState: m.AlertStateAlerting, prevState: m.AlertStateAlerting,
frequency: time.Minute * 10, frequency: time.Minute * 10,
sendReminder: true, sendReminder: true,
state: &m.AlertNotificationState{},
expect: true, expect: true,
}, },
@ -132,6 +125,27 @@ func TestShouldSendAlertNotification(t *testing.T) {
prevState: m.AlertStateOK, prevState: m.AlertStateOK,
state: &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()}, state: &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()},
expect: true,
},
{
name: "unknown -> ok",
prevState: m.AlertStateUnknown,
newState: m.AlertStateOK,
expect: false,
},
{
name: "unknown -> pending",
prevState: m.AlertStateUnknown,
newState: m.AlertStatePending,
expect: false,
},
{
name: "unknown -> alerting",
prevState: m.AlertStateUnknown,
newState: m.AlertStateAlerting,
expect: true, expect: true,
}, },
} }
@ -141,6 +155,10 @@ func TestShouldSendAlertNotification(t *testing.T) {
State: tc.prevState, State: tc.prevState,
}) })
if tc.state == nil {
tc.state = &m.AlertNotificationState{}
}
evalContext.Rule.State = tc.newState evalContext.Rule.State = tc.newState
nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency} nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency}

View File

@ -73,6 +73,9 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
// when two servers are raising. This makes sure that the server // when two servers are raising. This makes sure that the server
// with the last state change always sends a notification. // with the last state change always sends a notification.
evalContext.Rule.StateChanges = cmd.Result.StateChanges evalContext.Rule.StateChanges = cmd.Result.StateChanges
// Update the last state change of the alert rule in memory
evalContext.Rule.LastStateChange = time.Now()
} }
// save annotation // save annotation

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
"time"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
@ -18,6 +19,8 @@ type Rule struct {
Frequency int64 Frequency int64
Name string Name string
Message string Message string
LastStateChange time.Time
For time.Duration
NoDataState m.NoDataOption NoDataState m.NoDataOption
ExecutionErrorState m.ExecutionErrorOption ExecutionErrorState m.ExecutionErrorOption
State m.AlertStateType State m.AlertStateType
@ -100,6 +103,8 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
model.Message = ruleDef.Message model.Message = ruleDef.Message
model.Frequency = ruleDef.Frequency model.Frequency = ruleDef.Frequency
model.State = ruleDef.State model.State = ruleDef.State
model.LastStateChange = ruleDef.NewStateDate
model.For = ruleDef.For
model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data")) model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data"))
model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting")) model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting"))
model.StateChanges = ruleDef.StateChanges model.StateChanges = ruleDef.StateChanges

View File

@ -23,6 +23,7 @@
"message": "desc1", "message": "desc1",
"handler": 1, "handler": 1,
"frequency": "60s", "frequency": "60s",
"for": "2m",
"conditions": [ "conditions": [
{ {
"type": "query", "type": "query",

View File

@ -193,7 +193,8 @@ func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
if alertToUpdate.ContainsUpdates(alert) { if alertToUpdate.ContainsUpdates(alert) {
alert.Updated = timeNow() alert.Updated = timeNow()
alert.State = alertToUpdate.State alert.State = alertToUpdate.State
sess.MustCols("message") sess.MustCols("message", "for")
_, err := sess.ID(alert.Id).Update(alert) _, err := sess.ID(alert.Id).Update(alert)
if err != nil { if err != nil {
return err return err
@ -204,7 +205,7 @@ func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
} else { } else {
alert.Updated = timeNow() alert.Updated = timeNow()
alert.Created = timeNow() alert.Created = timeNow()
alert.State = m.AlertStatePending alert.State = m.AlertStateUnknown
alert.NewStateDate = timeNow() alert.NewStateDate = timeNow()
_, err := sess.Insert(alert) _, err := sess.Insert(alert)
@ -299,7 +300,7 @@ func PauseAlert(cmd *m.PauseAlertCommand) error {
params = append(params, string(m.AlertStatePaused)) params = append(params, string(m.AlertStatePaused))
params = append(params, timeNow()) params = append(params, timeNow())
} else { } else {
params = append(params, string(m.AlertStatePending)) params = append(params, string(m.AlertStateUnknown))
params = append(params, timeNow()) params = append(params, timeNow())
} }
@ -323,7 +324,7 @@ func PauseAllAlerts(cmd *m.PauseAllAlertCommand) error {
if cmd.Paused { if cmd.Paused {
newState = string(m.AlertStatePaused) newState = string(m.AlertStatePaused)
} else { } else {
newState = string(m.AlertStatePending) newState = string(m.AlertStateUnknown)
} }
res, err := sess.Exec(`UPDATE alert SET state = ?, new_state_date = ?`, newState, timeNow()) res, err := sess.Exec(`UPDATE alert SET state = ?, new_state_date = ?`, newState, timeNow())

View File

@ -109,7 +109,7 @@ func TestAlertingDataAccess(t *testing.T) {
So(alert.DashboardId, ShouldEqual, testDash.Id) So(alert.DashboardId, ShouldEqual, testDash.Id)
So(alert.PanelId, ShouldEqual, 1) So(alert.PanelId, ShouldEqual, 1)
So(alert.Name, ShouldEqual, "Alerting title") So(alert.Name, ShouldEqual, "Alerting title")
So(alert.State, ShouldEqual, "pending") So(alert.State, ShouldEqual, m.AlertStateUnknown)
So(alert.NewStateDate, ShouldNotBeNil) So(alert.NewStateDate, ShouldNotBeNil)
So(alert.EvalData, ShouldNotBeNil) So(alert.EvalData, ShouldNotBeNil)
So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test") So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test")
@ -154,7 +154,7 @@ func TestAlertingDataAccess(t *testing.T) {
So(query.Result[0].Name, ShouldEqual, "Name") So(query.Result[0].Name, ShouldEqual, "Name")
Convey("Alert state should not be updated", func() { Convey("Alert state should not be updated", func() {
So(query.Result[0].State, ShouldEqual, "pending") So(query.Result[0].State, ShouldEqual, m.AlertStateUnknown)
}) })
}) })

View File

@ -133,4 +133,8 @@ func addAlertMigrations(mg *Migrator) {
mg.AddMigration("create alert_notification_state table v1", NewAddTableMigration(alert_notification_state)) mg.AddMigration("create alert_notification_state table v1", NewAddTableMigration(alert_notification_state))
mg.AddMigration("add index alert_notification_state org_id & alert_id & notifier_id", mg.AddMigration("add index alert_notification_state org_id & alert_id & notifier_id",
NewAddIndexMigration(alert_notification_state, alert_notification_state.Indices[0])) NewAddIndexMigration(alert_notification_state, alert_notification_state.Indices[0]))
mg.AddMigration("Add for to alert table", NewAddColumnMigration(alertV1, &Column{
Name: "for", Type: DB_BigInt, Nullable: true,
}))
} }

View File

@ -57,6 +57,9 @@ var (
IsEnterprise bool IsEnterprise bool
ApplicationName string ApplicationName string
// packaging
Packaging = "unknown"
// Paths // Paths
HomePath string HomePath string
PluginsPath string PluginsPath string
@ -112,6 +115,7 @@ var (
ExternalUserMngLinkUrl string ExternalUserMngLinkUrl string
ExternalUserMngLinkName string ExternalUserMngLinkName string
ExternalUserMngInfo string ExternalUserMngInfo string
OAuthAutoLogin bool
ViewersCanEdit bool ViewersCanEdit bool
// Http auth // Http auth
@ -626,6 +630,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
auth := iniFile.Section("auth") auth := iniFile.Section("auth")
DisableLoginForm = auth.Key("disable_login_form").MustBool(false) DisableLoginForm = auth.Key("disable_login_form").MustBool(false)
DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false)
OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false)
SignoutRedirectUrl = auth.Key("signout_redirect_url").String() SignoutRedirectUrl = auth.Key("signout_redirect_url").String()
// anonymous access // anonymous access

View File

@ -32,6 +32,7 @@ func (s *SocialGoogle) IsSignupAllowed() bool {
func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
var data struct { var data struct {
Id string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
} }
@ -47,6 +48,7 @@ func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
} }
return &BasicUserInfo{ return &BasicUserInfo{
Id: data.Id,
Name: data.Name, Name: data.Name,
Email: data.Email, Email: data.Email,
Login: data.Email, Login: data.Email,

View File

@ -46,6 +46,7 @@ func init() {
"AWS/Billing": {"EstimatedCharges"}, "AWS/Billing": {"EstimatedCharges"},
"AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"}, "AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"},
"AWS/CloudSearch": {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"}, "AWS/CloudSearch": {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"},
"AWS/CloudHSM": {"HsmUnhealthy", "HsmTemperature", "HsmKeysSessionOccupied", "HsmKeysTokenOccupied", "HsmSslCtxsOccupied", "HsmSessionCount", "HsmUsersAvailable", "HsmUsersMax", "InterfaceEth2OctetsInput", "InterfaceEth2OctetsOutput"},
"AWS/Connect": {"CallsBreachingConcurrencyQuota", "CallBackNotDialableNumber", "CallRecordingUploadError", "CallsPerInterval", "ConcurrentCalls", "ConcurrentCallsPercentage", "ContactFlowErrors", "ContactFlowFatalErrors", "LongestQueueWaitTime", "MissedCalls", "MisconfiguredPhoneNumbers", "PublicSigningKeyUsage", "QueueCapacityExceededError", "QueueSize", "ThrottledCalls", "ToInstancePacketLossRate"}, "AWS/Connect": {"CallsBreachingConcurrencyQuota", "CallBackNotDialableNumber", "CallRecordingUploadError", "CallsPerInterval", "ConcurrentCalls", "ConcurrentCallsPercentage", "ContactFlowErrors", "ContactFlowFatalErrors", "LongestQueueWaitTime", "MissedCalls", "MisconfiguredPhoneNumbers", "PublicSigningKeyUsage", "QueueCapacityExceededError", "QueueSize", "ThrottledCalls", "ToInstancePacketLossRate"},
"AWS/DMS": {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"}, "AWS/DMS": {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"},
"AWS/DX": {"ConnectionState", "ConnectionBpsEgress", "ConnectionBpsIngress", "ConnectionPpsEgress", "ConnectionPpsIngress", "ConnectionCRCErrorCount", "ConnectionLightLevelTx", "ConnectionLightLevelRx"}, "AWS/DX": {"ConnectionState", "ConnectionBpsEgress", "ConnectionBpsIngress", "ConnectionPpsEgress", "ConnectionPpsIngress", "ConnectionCRCErrorCount", "ConnectionLightLevelTx", "ConnectionLightLevelRx"},
@ -121,6 +122,7 @@ func init() {
"AWS/Billing": {"ServiceName", "LinkedAccount", "Currency"}, "AWS/Billing": {"ServiceName", "LinkedAccount", "Currency"},
"AWS/CloudFront": {"DistributionId", "Region"}, "AWS/CloudFront": {"DistributionId", "Region"},
"AWS/CloudSearch": {}, "AWS/CloudSearch": {},
"AWS/CloudHSM": {"Region", "ClusterId", "HsmId"},
"AWS/Connect": {"InstanceId", "MetricGroup", "Participant", "QueueName", "Stream Type", "Type of Connection"}, "AWS/Connect": {"InstanceId", "MetricGroup", "Participant", "QueueName", "Stream Type", "Type of Connection"},
"AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"}, "AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"},
"AWS/DX": {"ConnectionId"}, "AWS/DX": {"ConnectionId"},

View File

@ -36,17 +36,13 @@ export class Switch extends PureComponent<Props, State> {
} }
return ( return (
<div className="gf-form"> <label htmlFor={labelId} className="gf-form-switch-container">
{label && ( {label && <label className={labelClassName}>{label}</label>}
<label htmlFor={labelId} className={labelClassName}>
{label}
</label>
)}
<div className={switchClassName}> <div className={switchClassName}>
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} /> <input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
<label htmlFor={labelId} /> <span className="gf-form-switch__slider" />
</div>
</div> </div>
</label>
); );
} }
} }

View File

@ -64,10 +64,10 @@
<div class="search-results" ng-show="ctrl.sections.length > 0"> <div class="search-results" ng-show="ctrl.sections.length > 0">
<div class="search-results-filter-row"> <div class="search-results-filter-row">
<gf-form-switch <gf-form-checkbox
on-change="ctrl.onSelectAllChanged()" on-change="ctrl.onSelectAllChanged()"
checked="ctrl.selectAllChecked" checked="ctrl.selectAllChecked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result-filter-row__checkbox" switch-class="gf-form-switch--transparent"
/> />
<div class="search-results-filter-row__filters"> <div class="search-results-filter-row__filters">
<div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)"> <div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)">

View File

@ -1,12 +1,12 @@
<div ng-repeat="section in ctrl.results" class="search-section"> <div ng-repeat="section in ctrl.results" class="search-section">
<div class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)"> <div class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)">
<div ng-click="ctrl.toggleSelection(section, $event)"> <div ng-click="ctrl.toggleSelection(section, $event)">
<gf-form-switch <gf-form-checkbox
ng-show="ctrl.editable" ng-show="ctrl.editable"
on-change="ctrl.selectionChanged($event)" on-change="ctrl.selectionChanged($event)"
checked="section.checked" checked="section.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__section"> switch-class="gf-form-switch--transparent gf-form-switch--search-result__section">
</gf-form-switch> </gf-form-checkbox>
</div> </div>
<i class="search-section__header__icon" ng-class="section.icon"></i> <i class="search-section__header__icon" ng-class="section.icon"></i>
<span class="search-section__header__text">{{::section.title}}</span> <span class="search-section__header__text">{{::section.title}}</span>
@ -22,12 +22,12 @@
<div ng-if="section.expanded"> <div ng-if="section.expanded">
<a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" > <a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
<div ng-click="ctrl.toggleSelection(item, $event)"> <div ng-click="ctrl.toggleSelection(item, $event)">
<gf-form-switch <gf-form-checkbox
ng-show="ctrl.editable" ng-show="ctrl.editable"
on-change="ctrl.selectionChanged()" on-change="ctrl.selectionChanged()"
checked="item.checked" checked="item.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__item"> switch-class="gf-form-switch--transparent gf-form-switch--search-result__item">
</gf-form-switch> </gf-form-checkbox>
</div> </div>
<span class="search-item__icon"> <span class="search-item__icon">
<i class="gicon mini gicon-dashboard-list"></i> <i class="gicon mini gicon-dashboard-list"></i>

View File

@ -1,16 +1,33 @@
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
const template = ` const template = `
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer" ng-show="ctrl.label"> <label for="check-{{ctrl.id}}" class="gf-form-switch-container">
<div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
{{ctrl.label}} {{ctrl.label}}
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center"> <info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
{{ctrl.tooltip}} {{ctrl.tooltip}}
</info-popover> </info-popover>
</label> </div>
<div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show"> <div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show">
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()"> <input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
<label for="check-{{ctrl.id}}" data-on="Yes" data-off="No"></label> <span class="gf-form-switch__slider"></span>
</div> </div>
</label>
`;
const checkboxTemplate = `
<label for="check-{{ctrl.id}}" class="gf-form-switch-container">
<div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
{{ctrl.label}}
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
{{ctrl.tooltip}}
</info-popover>
</div>
<div class="gf-form-checkbox {{ctrl.switchClass}}" ng-if="ctrl.show">
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
<span class="gf-form-switch__checkbox"></span>
</div>
</label>
`; `;
export class SwitchCtrl { export class SwitchCtrl {
@ -51,4 +68,23 @@ export function switchDirective() {
}; };
} }
export function checkboxDirective() {
return {
restrict: 'E',
controller: SwitchCtrl,
controllerAs: 'ctrl',
bindToController: true,
scope: {
checked: '=',
label: '@',
labelClass: '@',
tooltip: '@',
switchClass: '@',
onChange: '&',
},
template: checkboxTemplate,
};
}
coreModule.directive('gfFormSwitch', switchDirective); coreModule.directive('gfFormSwitch', switchDirective);
coreModule.directive('gfFormCheckbox', checkboxDirective);

View File

@ -31,6 +31,7 @@ export interface LogSearchMatch {
} }
export interface LogRow { export interface LogRow {
duplicates?: number;
entry: string; entry: string;
key: string; // timestamp + labels key: string; // timestamp + labels
labels: string; labels: string;
@ -71,6 +72,53 @@ export interface LogsStreamLabels {
[key: string]: string; [key: string]: string;
} }
export enum LogsDedupStrategy {
none = 'none',
exact = 'exact',
numbers = 'numbers',
signature = 'signature',
}
const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean {
switch (strategy) {
case LogsDedupStrategy.exact:
// Exact still strips dates
return row.entry.replace(isoDateRegexp, '') === other.entry.replace(isoDateRegexp, '');
case LogsDedupStrategy.numbers:
return row.entry.replace(/\d/g, '') === other.entry.replace(/\d/g, '');
case LogsDedupStrategy.signature:
return row.entry.replace(/\w/g, '') === other.entry.replace(/\w/g, '');
default:
return false;
}
}
export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): LogsModel {
if (strategy === LogsDedupStrategy.none) {
return logs;
}
const dedupedRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => {
const previous = result[result.length - 1];
if (index > 0 && isDuplicateRow(row, previous, strategy)) {
previous.duplicates++;
} else {
row.duplicates = 0;
result.push(row);
}
return result;
}, []);
return {
...logs,
rows: dedupedRows,
};
}
export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] { export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] {
// Graph time series by log level // Graph time series by log level
const seriesByLevel = {}; const seriesByLevel = {};

View File

@ -26,7 +26,7 @@ export class Analytics {
init() { init() {
this.$rootScope.$on('$viewContentLoaded', () => { this.$rootScope.$on('$viewContentLoaded', () => {
const track = { page: this.$location.url() }; const track = { location: this.$location.url() };
const ga = (window as any).ga || this.gaInit(); const ga = (window as any).ga || this.gaInit();
ga('set', track); ga('set', track);
ga('send', 'pageview'); ga('send', 'pageview');

View File

@ -32,8 +32,8 @@ export class KeybindingSrv {
this.setupGlobal(); this.setupGlobal();
appEvents.on('show-modal', () => (this.modalOpen = true)); appEvents.on('show-modal', () => (this.modalOpen = true));
$rootScope.onAppEvent('timepickerOpen', () => (this.timepickerOpen = true)); appEvents.on('timepickerOpen', () => (this.timepickerOpen = true));
$rootScope.onAppEvent('timepickerClosed', () => (this.timepickerOpen = false)); appEvents.on('timepickerClosed', () => (this.timepickerOpen = false));
} }
setupGlobal() { setupGlobal() {

View File

@ -0,0 +1,108 @@
import { dedupLogRows, LogsDedupStrategy, LogsModel } from '../logs_model';
describe('dedupLogRows()', () => {
test('should return rows as is when dedup is set to none', () => {
const logs = {
rows: [
{
entry: 'WARN test 1.23 on [xxx]',
},
{
entry: 'WARN test 1.23 on [xxx]',
},
],
};
expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.none).rows).toMatchObject(logs.rows);
});
test('should dedup on exact matches', () => {
const logs = {
rows: [
{
entry: 'WARN test 1.23 on [xxx]',
},
{
entry: 'WARN test 1.23 on [xxx]',
},
{
entry: 'INFO test 2.44 on [xxx]',
},
{
entry: 'WARN test 1.23 on [xxx]',
},
],
};
expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.exact).rows).toEqual([
{
duplicates: 1,
entry: 'WARN test 1.23 on [xxx]',
},
{
duplicates: 0,
entry: 'INFO test 2.44 on [xxx]',
},
{
duplicates: 0,
entry: 'WARN test 1.23 on [xxx]',
},
]);
});
test('should dedup on number matches', () => {
const logs = {
rows: [
{
entry: 'WARN test 1.2323423 on [xxx]',
},
{
entry: 'WARN test 1.23 on [xxx]',
},
{
entry: 'INFO test 2.44 on [xxx]',
},
{
entry: 'WARN test 1.23 on [xxx]',
},
],
};
expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.numbers).rows).toEqual([
{
duplicates: 1,
entry: 'WARN test 1.2323423 on [xxx]',
},
{
duplicates: 0,
entry: 'INFO test 2.44 on [xxx]',
},
{
duplicates: 0,
entry: 'WARN test 1.23 on [xxx]',
},
]);
});
test('should dedup on signature matches', () => {
const logs = {
rows: [
{
entry: 'WARN test 1.2323423 on [xxx]',
},
{
entry: 'WARN test 1.23 on [xxx]',
},
{
entry: 'INFO test 2.44 on [xxx]',
},
{
entry: 'WARN test 1.23 on [xxx]',
},
],
};
expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.signature).rows).toEqual([
{
duplicates: 3,
entry: 'WARN test 1.2323423 on [xxx]',
},
]);
});
});

View File

@ -7,6 +7,7 @@ export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)';
export const OK_COLOR = 'rgba(11, 237, 50, 1)'; export const OK_COLOR = 'rgba(11, 237, 50, 1)';
export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)'; export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)';
export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)'; export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)';
export const PENDING_COLOR = 'rgba(247, 149, 32, 1)';
export const REGION_FILL_ALPHA = 0.09; export const REGION_FILL_ALPHA = 0.09;
const colors = [ const colors = [

View File

@ -29,6 +29,7 @@ export class AlertRuleList extends PureComponent<Props, any> {
{ text: 'Alerting', value: 'alerting' }, { text: 'Alerting', value: 'alerting' },
{ text: 'No Data', value: 'no_data' }, { text: 'No Data', value: 'no_data' },
{ text: 'Paused', value: 'paused' }, { text: 'Paused', value: 'paused' },
{ text: 'Pending', value: 'pending' },
]; ];
componentDidMount() { componentDidMount() {

View File

@ -169,6 +169,7 @@ export class AlertTabCtrl {
alert.frequency = alert.frequency || '1m'; alert.frequency = alert.frequency || '1m';
alert.handler = alert.handler || 1; alert.handler = alert.handler || 1;
alert.notifications = alert.notifications || []; alert.notifications = alert.notifications || [];
alert.for = alert.for || '0m';
const defaultName = this.panel.title + ' alert'; const defaultName = this.panel.title + ' alert';
alert.name = alert.name || defaultName; alert.name = alert.name || defaultName;
@ -217,7 +218,7 @@ export class AlertTabCtrl {
buildDefaultCondition() { buildDefaultCondition() {
return { return {
type: 'query', type: 'query',
query: { params: ['A', '15m', 'now'] }, query: { params: ['A', '5m', 'now'] },
reducer: { type: 'avg', params: [] }, reducer: { type: 'avg', params: [] },
evaluator: { type: 'gt', params: [null] }, evaluator: { type: 'gt', params: [null] },
operator: { type: 'and' }, operator: { type: 'and' },
@ -354,6 +355,7 @@ export class AlertTabCtrl {
enable() { enable() {
this.panel.alert = {}; this.panel.alert = {};
this.initModel(); this.initModel();
this.panel.alert.for = '5m'; //default value for new alerts. for existing alerts we use 0m to avoid breaking changes
} }
evaluatorParamsChanged() { evaluatorParamsChanged() {

View File

@ -81,6 +81,12 @@ exports[`Render should render alert rules 1`] = `
> >
Paused Paused
</option> </option>
<option
key="pending"
value="pending"
>
Pending
</option>
</select> </select>
</div> </div>
</div> </div>
@ -230,6 +236,12 @@ exports[`Render should render component 1`] = `
> >
Paused Paused
</option> </option>
<option
key="pending"
value="pending"
>
Pending
</option>
</select> </select>
</div> </div>
</div> </div>

View File

@ -29,8 +29,20 @@
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-6">Name</span> <span class="gf-form-label width-6">Name</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name"> <input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
<span class="gf-form-label">Evaluate every</span> </div>
<input class="gf-form-input max-width-5" type="text" ng-model="ctrl.alert.frequency"></input> <div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Evaluate every</span>
<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency">
</div>
<div class="gf-form max-width-11">
<label class="gf-form-label width-5">For</label>
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m">
<info-popover mode="right-absolute">
If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending.
Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications.
</info-popover>
</div>
</div> </div>
</div> </div>

View File

@ -99,6 +99,13 @@ function getStateDisplayModel(state) {
stateClass: 'alert-state-warning', stateClass: 'alert-state-warning',
}; };
} }
case 'unknown': {
return {
text: 'UNKNOWN',
iconClass: 'fa fa-question',
stateClass: 'alert-state-paused',
};
}
} }
throw { message: 'Unknown alert state' }; throw { message: 'Unknown alert state' };

View File

@ -32,7 +32,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv,
if (event.alertId) { if (event.alertId) {
const stateModel = alertDef.getStateDisplayModel(event.newState); const stateModel = alertDef.getStateDisplayModel(event.newState);
titleStateClass = stateModel.stateClass; titleStateClass = stateModel.stateClass;
title = `<i class="icon-gf ${stateModel.iconClass}"></i> ${stateModel.text}`; title = `<i class="${stateModel.iconClass}"></i> ${stateModel.text}`;
text = alertDef.getAlertAnnotationInfo(event); text = alertDef.getAlertAnnotationInfo(event);
if (event.text) { if (event.text) {
text = text + '<br />' + event.text; text = text + '<br />' + event.text;

View File

@ -7,6 +7,7 @@ import {
OK_COLOR, OK_COLOR,
ALERTING_COLOR, ALERTING_COLOR,
NO_DATA_COLOR, NO_DATA_COLOR,
PENDING_COLOR,
DEFAULT_ANNOTATION_COLOR, DEFAULT_ANNOTATION_COLOR,
REGION_FILL_ALPHA, REGION_FILL_ALPHA,
} from 'app/core/utils/colors'; } from 'app/core/utils/colors';
@ -71,6 +72,11 @@ export class EventManager {
position: 'BOTTOM', position: 'BOTTOM',
markerSize: 5, markerSize: 5,
}, },
$__pending: {
color: PENDING_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
$__editing: { $__editing: {
color: DEFAULT_ANNOTATION_COLOR, color: DEFAULT_ANNOTATION_COLOR,
position: 'BOTTOM', position: 'BOTTOM',

View File

@ -77,6 +77,10 @@ export class DashboardSrv {
postSave(clone, data) { postSave(clone, data) {
this.dash.version = data.version; this.dash.version = data.version;
// important that these happens before location redirect below
this.$rootScope.appEvent('dashboard-saved', this.dash);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
const newUrl = locationUtil.stripBaseFromUrl(data.url); const newUrl = locationUtil.stripBaseFromUrl(data.url);
const currentPath = this.$location.path(); const currentPath = this.$location.path();
@ -84,9 +88,6 @@ export class DashboardSrv {
this.$location.url(newUrl).replace(); this.$location.url(newUrl).replace();
} }
this.$rootScope.appEvent('dashboard-saved', this.dash);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
return this.dash; return this.dash;
} }

View File

@ -43,7 +43,7 @@ export class PanelHeaderCorner extends PureComponent<Props> {
<div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} /> <div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
{panel.links && {panel.links &&
panel.links.length > 0 && ( panel.links.length > 0 && (
<ul> <ul className="text-left">
{panel.links.map((link, idx) => { {panel.links.map((link, idx) => {
const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars); const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
return ( return (
@ -74,7 +74,7 @@ export class PanelHeaderCorner extends PureComponent<Props> {
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? ( {infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
<Tooltip <Tooltip
content={this.getInfoContent} content={this.getInfoContent}
className="absolute" className="popper__manager--block"
refClassName={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`} refClassName={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}
> >
<i className="fa" /> <i className="fa" />

View File

@ -30,8 +30,8 @@
<tbody> <tbody>
<tr ng-repeat="revision in ctrl.revisions"> <tr ng-repeat="revision in ctrl.revisions">
<td class="filter-table__switch-cell" bs-tooltip="!revision.checked && ctrl.canCompare ? 'You can only compare 2 versions at a time' : ''" data-placement="right"> <td class="filter-table__switch-cell" bs-tooltip="!revision.checked && ctrl.canCompare ? 'You can only compare 2 versions at a time' : ''" data-placement="right">
<gf-form-switch switch-class="gf-form-switch--table-cell" checked="revision.checked" on-change="ctrl.revisionSelectionChanged()" ng-disabled="!revision.checked && ctrl.canCompare"> <gf-form-checkbox switch-class="gf-form-switch--table-cell" checked="revision.checked" on-change="ctrl.revisionSelectionChanged()" ng-disabled="!revision.checked && ctrl.canCompare">
</gf-form-switch> </gf-form-checkbox>
</td> </td>
<td class="text-center">{{revision.version}}</td> <td class="text-center">{{revision.version}}</td>
<td>{{revision.createdDateString}}</td> <td>{{revision.createdDateString}}</td>

View File

@ -142,7 +142,7 @@ export class PanelModel {
setViewMode(fullscreen: boolean, isEditing: boolean) { setViewMode(fullscreen: boolean, isEditing: boolean) {
this.fullscreen = fullscreen; this.fullscreen = fullscreen;
this.isEditing = isEditing; this.isEditing = isEditing;
this.events.emit('panel-size-changed'); this.events.emit('view-mode-changed');
} }
updateGridPos(newPos: GridPos) { updateGridPos(newPos: GridPos) {

View File

@ -126,8 +126,7 @@ export class DashboardViewState {
if (!panel.fullscreen) { if (!panel.fullscreen) {
this.enterFullscreen(panel); this.enterFullscreen(panel);
} else { } else if (this.dashboard.meta.isEditing !== this.state.edit) {
// already in fullscreen view just update the view mode
this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit); this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
} }
} else if (this.fullscreenPanel) { } else if (this.fullscreenPanel) {

View File

@ -3,8 +3,9 @@ import { hot } from 'react-hot-loader';
import Select from 'react-select'; import Select from 'react-select';
import _ from 'lodash'; import _ from 'lodash';
import { DataSource } from 'app/types/datasources';
import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore'; import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
import { RawTimeRange } from 'app/types/series'; import { RawTimeRange, DataQuery } from 'app/types/series';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors'; import colors from 'app/core/utils/colors';
import store from 'app/core/store'; import store from 'app/core/store';
@ -16,7 +17,9 @@ import PickerOption from 'app/core/components/Picker/PickerOption';
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'; import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage'; import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import Panel from './Panel';
import QueryRows from './QueryRows'; import QueryRows from './QueryRows';
import Graph from './Graph'; import Graph from './Graph';
import Logs from './Logs'; import Logs from './Logs';
@ -24,7 +27,6 @@ import Table from './Table';
import ErrorBoundary from './ErrorBoundary'; import ErrorBoundary from './ErrorBoundary';
import TimePicker from './TimePicker'; import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { DataSource } from 'app/types/datasources';
const MAX_HISTORY_ITEMS = 100; const MAX_HISTORY_ITEMS = 100;
@ -77,7 +79,7 @@ function updateHistory(history: HistoryItem[], datasourceId: string, queries: st
} }
interface ExploreProps { interface ExploreProps {
datasourceSrv: any; datasourceSrv: DatasourceSrv;
onChangeSplit: (split: boolean, state?: ExploreState) => void; onChangeSplit: (split: boolean, state?: ExploreState) => void;
onSaveState: (key: string, state: ExploreState) => void; onSaveState: (key: string, state: ExploreState) => void;
position: string; position: string;
@ -92,6 +94,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/** /**
* Current query expressions of the rows including their modifications, used for running queries. * Current query expressions of the rows including their modifications, used for running queries.
* Not kept in component state to prevent edit-render roundtrips. * Not kept in component state to prevent edit-render roundtrips.
* TODO: make this generic (other datasources might not have string representations of current query state)
*/ */
queryExpressions: string[]; queryExpressions: string[];
/** /**
@ -125,6 +128,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
range: initialRange, range: initialRange,
showingGraph: true, showingGraph: true,
showingLogs: true, showingLogs: true,
showingStartPage: false,
showingTable: true, showingTable: true,
supportsGraph: null, supportsGraph: null,
supportsLogs: null, supportsLogs: null,
@ -164,7 +168,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
} }
async setDatasource(datasource: DataSource) { async setDatasource(datasource: any, origin?: DataSource) {
const supportsGraph = datasource.meta.metrics; const supportsGraph = datasource.meta.metrics;
const supportsLogs = datasource.meta.logs; const supportsLogs = datasource.meta.logs;
const supportsTable = datasource.meta.metrics; const supportsTable = datasource.meta.metrics;
@ -193,12 +197,33 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasource.init(); datasource.init();
} }
// Keep queries but reset edit state // Check if queries can be imported from previously selected datasource
let queryExpressions = this.queryExpressions;
if (origin) {
if (origin.meta.id === datasource.meta.id) {
// Keep same queries if same type of datasource
queryExpressions = [...this.queryExpressions];
} else if (datasource.importQueries) {
// Datasource-specific importers, wrapping to satisfy interface
const wrappedQueries: DataQuery[] = this.queryExpressions.map((query, index) => ({
refId: String(index),
expr: query,
}));
const modifiedQueries: DataQuery[] = await datasource.importQueries(wrappedQueries, origin.meta);
queryExpressions = modifiedQueries.map(({ expr }) => expr);
} else {
// Default is blank queries
queryExpressions = this.queryExpressions.map(() => '');
}
}
// Reset edit state with new queries
const nextQueries = this.state.queries.map((q, i) => ({ const nextQueries = this.state.queries.map((q, i) => ({
...q, ...q,
key: generateQueryKey(i), key: generateQueryKey(i),
query: this.queryExpressions[i], query: queryExpressions[i],
})); }));
this.queryExpressions = queryExpressions;
// Custom components // Custom components
const StartPage = datasource.pluginExports.ExploreStartPage; const StartPage = datasource.pluginExports.ExploreStartPage;
@ -215,6 +240,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasourceLoading: false, datasourceLoading: false,
datasourceName: datasource.name, datasourceName: datasource.name,
queries: nextQueries, queries: nextQueries,
showingStartPage: Boolean(StartPage),
}, },
() => { () => {
if (datasourceError === null) { if (datasourceError === null) {
@ -258,6 +284,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onChangeDatasource = async option => { onChangeDatasource = async option => {
const origin = this.state.datasource;
this.setState({ this.setState({
datasource: null, datasource: null,
datasourceError: null, datasourceError: null,
@ -266,7 +293,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}); });
const datasourceName = option.value; const datasourceName = option.value;
const datasource = await this.props.datasourceSrv.get(datasourceName); const datasource = await this.props.datasourceSrv.get(datasourceName);
this.setDatasource(datasource); this.setDatasource(datasource as any, origin);
}; };
onChangeQuery = (value: string, index: number, override?: boolean) => { onChangeQuery = (value: string, index: number, override?: boolean) => {
@ -305,10 +332,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onClickClear = () => { onClickClear = () => {
this.queryExpressions = ['']; this.queryExpressions = [''];
this.setState( this.setState(
{ prevState => ({
queries: ensureQueries(), queries: ensureQueries(),
queryTransactions: [], queryTransactions: [],
}, showingStartPage: Boolean(prevState.StartPage),
}),
this.saveState this.saveState
); );
}; };
@ -539,6 +567,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return { return {
queryTransactions: nextQueryTransactions, queryTransactions: nextQueryTransactions,
showingStartPage: false,
}; };
}); });
@ -765,16 +794,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
range, range,
showingGraph, showingGraph,
showingLogs, showingLogs,
showingStartPage,
showingTable, showingTable,
supportsGraph, supportsGraph,
supportsLogs, supportsLogs,
supportsTable, supportsTable,
} = this.state; } = this.state;
const showingBoth = showingGraph && showingTable; const graphHeight = showingGraph && showingTable ? '200px' : '400px';
const graphHeight = showingBoth ? '200px' : '400px';
const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
const logsButtonActive = showingLogs ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore'; const exploreClass = split ? 'explore explore-split' : 'explore';
const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined; const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined;
const graphRangeIntervals = getIntervals(graphRange, datasource, this.el ? this.el.offsetWidth : 0); const graphRangeIntervals = getIntervals(graphRange, datasource, this.el ? this.el.offsetWidth : 0);
@ -799,8 +825,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
) )
: undefined; : undefined;
const loading = queryTransactions.some(qt => !qt.done); const loading = queryTransactions.some(qt => !qt.done);
const showStartPages = StartPage && queryTransactions.length === 0;
const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length;
return ( return (
<div className={exploreClass} ref={this.getRef}> <div className={exploreClass} ref={this.getRef}>
@ -889,47 +913,38 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/> />
<main className="m-t-2"> <main className="m-t-2">
<ErrorBoundary> <ErrorBoundary>
{showStartPages && <StartPage onClickQuery={this.onClickQuery} />} {showingStartPage && <StartPage onClickQuery={this.onClickQuery} />}
{!showStartPages && ( {!showingStartPage && (
<> <>
{viewModeCount > 1 && ( {supportsGraph && (
<div className="result-options"> <Panel
{supportsGraph ? ( label="Graph"
<button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}> isOpen={showingGraph}
Graph loading={graphLoading}
</button> onToggle={this.onClickGraphButton}
) : null} >
{supportsTable ? (
<button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
Table
</button>
) : null}
{supportsLogs ? (
<button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
Logs
</button>
) : null}
</div>
)}
{supportsGraph &&
showingGraph && (
<Graph <Graph
data={graphResult} data={graphResult}
height={graphHeight} height={graphHeight}
loading={graphLoading}
id={`explore-graph-${position}`} id={`explore-graph-${position}`}
onChangeTime={this.onChangeTime} onChangeTime={this.onChangeTime}
range={graphRange} range={graphRange}
split={split} split={split}
/> />
</Panel>
)} )}
{supportsTable && showingTable ? ( {supportsTable && (
<div className="panel-container m-t-2"> <Panel
label="Table"
loading={tableLoading}
isOpen={showingTable}
onToggle={this.onClickTableButton}
>
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} /> <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
</div> </Panel>
) : null} )}
{supportsLogs && showingLogs ? ( {supportsLogs && (
<Panel label="Logs" loading={logsLoading} isOpen={showingLogs} onToggle={this.onClickLogsButton}>
<Logs <Logs
data={logsResult} data={logsResult}
loading={logsLoading} loading={logsLoading}
@ -937,7 +952,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onChangeTime={this.onChangeTime} onChangeTime={this.onChangeTime}
range={range} range={range}
/> />
) : null} </Panel>
)}
</> </>
)} )}
</ErrorBoundary> </ErrorBoundary>

View File

@ -77,7 +77,6 @@ interface GraphProps {
data: any[]; data: any[];
height?: string; // e.g., '200px' height?: string; // e.g., '200px'
id?: string; id?: string;
loading?: boolean;
range: RawTimeRange; range: RawTimeRange;
split?: boolean; split?: boolean;
size?: { width: number; height: number }; size?: { width: number; height: number };
@ -188,12 +187,11 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
} }
render() { render() {
const { height = '100px', id = 'graph', loading = false } = this.props; const { height = '100px', id = 'graph' } = this.props;
const data = this.getGraphData(); const data = this.getGraphData();
return ( return (
<div className="panel-container"> <>
{loading && <div className="explore-panel__loader" />}
{this.props.data && {this.props.data &&
this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && this.props.data.length > MAX_NUMBER_OF_TIME_SERIES &&
!this.state.showAllTimeSeries && ( !this.state.showAllTimeSeries && (
@ -207,7 +205,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
)} )}
<div id={id} className="explore-graph" style={{ height }} /> <div id={id} className="explore-graph" style={{ height }} />
<Legend data={data} /> <Legend data={data} />
</div> </>
); );
} }
} }

View File

@ -2,7 +2,7 @@ import React, { Fragment, PureComponent } from 'react';
import Highlighter from 'react-highlight-words'; import Highlighter from 'react-highlight-words';
import { RawTimeRange } from 'app/types/series'; import { RawTimeRange } from 'app/types/series';
import { LogsModel } from 'app/core/logs_model'; import { LogsDedupStrategy, LogsModel, dedupLogRows } from 'app/core/logs_model';
import { findHighlightChunksInText } from 'app/core/utils/text'; import { findHighlightChunksInText } from 'app/core/utils/text';
import { Switch } from 'app/core/components/Switch/Switch'; import { Switch } from 'app/core/components/Switch/Switch';
@ -32,6 +32,7 @@ interface LogsProps {
} }
interface LogsState { interface LogsState {
dedup: LogsDedupStrategy;
showLabels: boolean; showLabels: boolean;
showLocalTime: boolean; showLocalTime: boolean;
showUtc: boolean; showUtc: boolean;
@ -39,11 +40,21 @@ interface LogsState {
export default class Logs extends PureComponent<LogsProps, LogsState> { export default class Logs extends PureComponent<LogsProps, LogsState> {
state = { state = {
dedup: LogsDedupStrategy.none,
showLabels: true, showLabels: true,
showLocalTime: true, showLocalTime: true,
showUtc: false, showUtc: false,
}; };
onChangeDedup = (dedup: LogsDedupStrategy) => {
this.setState(prevState => {
if (prevState.dedup === dedup) {
return { dedup: LogsDedupStrategy.none };
}
return { dedup };
});
};
onChangeLabels = (event: React.SyntheticEvent) => { onChangeLabels = (event: React.SyntheticEvent) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
this.setState({ this.setState({
@ -67,9 +78,18 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
render() { render() {
const { className = '', data, loading = false, position, range } = this.props; const { className = '', data, loading = false, position, range } = this.props;
const { showLabels, showLocalTime, showUtc } = this.state; const { dedup, showLabels, showLocalTime, showUtc } = this.state;
const hasData = data && data.rows && data.rows.length > 0; const hasData = data && data.rows && data.rows.length > 0;
const cssColumnSizes = ['4px']; const dedupedData = dedupLogRows(data, dedup);
const dedupCount = dedupedData.rows.reduce((sum, row) => sum + row.duplicates, 0);
const meta = [...data.meta];
if (dedup !== LogsDedupStrategy.none) {
meta.push({
label: 'Dedup count',
value: String(dedupCount),
});
}
const cssColumnSizes = ['3px']; // Log-level indicator line
if (showUtc) { if (showUtc) {
cssColumnSizes.push('minmax(100px, max-content)'); cssColumnSizes.push('minmax(100px, max-content)');
} }
@ -97,15 +117,39 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
/> />
</div> </div>
<div className="panel-container logs-options"> <div className="logs-options">
<div className="logs-controls"> <div className="logs-controls">
<Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} small /> <Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} small />
<Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} small /> <Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} small />
<Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} small /> <Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} small />
<Switch
label="Dedup: off"
checked={dedup === LogsDedupStrategy.none}
onChange={() => this.onChangeDedup(LogsDedupStrategy.none)}
small
/>
<Switch
label="Dedup: exact"
checked={dedup === LogsDedupStrategy.exact}
onChange={() => this.onChangeDedup(LogsDedupStrategy.exact)}
small
/>
<Switch
label="Dedup: numbers"
checked={dedup === LogsDedupStrategy.numbers}
onChange={() => this.onChangeDedup(LogsDedupStrategy.numbers)}
small
/>
<Switch
label="Dedup: signature"
checked={dedup === LogsDedupStrategy.signature}
onChange={() => this.onChangeDedup(LogsDedupStrategy.signature)}
small
/>
{hasData && {hasData &&
data.meta && ( meta && (
<div className="logs-meta"> <div className="logs-meta">
{data.meta.map(item => ( {meta.map(item => (
<div className="logs-meta-item" key={item.label}> <div className="logs-meta-item" key={item.label}>
<span className="logs-meta-item__label">{item.label}:</span> <span className="logs-meta-item__label">{item.label}:</span>
<span className="logs-meta-item__value">{item.value}</span> <span className="logs-meta-item__value">{item.value}</span>
@ -116,13 +160,19 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
</div> </div>
</div> </div>
<div className="panel-container">
{loading && <div className="explore-panel__loader" />}
<div className="logs-entries" style={logEntriesStyle}> <div className="logs-entries" style={logEntriesStyle}>
{hasData && {hasData &&
data.rows.map(row => ( dedupedData.rows.map(row => (
<Fragment key={row.key}> <Fragment key={row.key}>
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} /> <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''}>
{row.duplicates > 0 && (
<div className="logs-row-level__duplicates" title={`${row.duplicates} duplicates`}>
{Array.apply(null, { length: row.duplicates }).map(index => (
<div className="logs-row-level__duplicate" key={`${index}`} />
))}
</div>
)}
</div>
{showUtc && <div title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>{row.timestamp}</div>} {showUtc && <div title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>{row.timestamp}</div>}
{showLocalTime && <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>} {showLocalTime && <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>}
{showLabels && ( {showLabels && (
@ -143,7 +193,6 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
</div> </div>
{!loading && !hasData && 'No data was returned.'} {!loading && !hasData && 'No data was returned.'}
</div> </div>
</div>
); );
} }
} }

View File

@ -0,0 +1,34 @@
import React, { PureComponent } from 'react';
interface Props {
isOpen: boolean;
label: string;
loading?: boolean;
onToggle: (isOpen: boolean) => void;
}
export default class Panel extends PureComponent<Props> {
onClickToggle = () => this.props.onToggle(!this.props.isOpen);
render() {
const { isOpen, loading } = this.props;
const iconClass = isOpen ? 'fa fa-caret-up' : 'fa fa-caret-down';
const loaderClass = loading ? 'explore-panel__loader explore-panel__loader--active' : 'explore-panel__loader';
return (
<div className="explore-panel panel-container">
<div className="explore-panel__header" onClick={this.onClickToggle}>
<div className="explore-panel__header-buttons">
<span className={iconClass} />
</div>
<div className="explore-panel__header-label">{this.props.label}</div>
</div>
{isOpen && (
<div className="explore-panel__body">
<div className={loaderClass} />
{this.props.children}
</div>
)}
</div>
);
}
}

View File

@ -1,9 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = ` exports[`Render should render component 1`] = `
<div <Fragment>
className="panel-container"
>
<div <div
className="explore-graph" className="explore-graph"
id="graph" id="graph"
@ -456,13 +454,11 @@ exports[`Render should render component 1`] = `
] ]
} }
/> />
</div> </Fragment>
`; `;
exports[`Render should render component with disclaimer 1`] = ` exports[`Render should render component with disclaimer 1`] = `
<div <Fragment>
className="panel-container"
>
<div <div
className="time-series-disclaimer" className="time-series-disclaimer"
> >
@ -952,13 +948,11 @@ exports[`Render should render component with disclaimer 1`] = `
] ]
} }
/> />
</div> </Fragment>
`; `;
exports[`Render should show query return no time series 1`] = ` exports[`Render should show query return no time series 1`] = `
<div <Fragment>
className="panel-container"
>
<div <div
className="explore-graph" className="explore-graph"
id="graph" id="graph"
@ -971,5 +965,5 @@ exports[`Render should show query return no time series 1`] = `
<Legend <Legend
data={Array []} data={Array []}
/> />
</div> </Fragment>
`; `;

View File

@ -82,7 +82,6 @@ export class MetricsTabCtrl {
return; return;
} }
this.datasourceInstance = option.datasource;
this.setDatasource(option.datasource); this.setDatasource(option.datasource);
this.updateDatasourceOptions(); this.updateDatasourceOptions();
} }
@ -102,6 +101,7 @@ export class MetricsTabCtrl {
}); });
} }
this.datasourceInstance = datasource;
this.panel.datasource = datasource.value; this.panel.datasource = datasource.value;
this.panel.refresh(); this.panel.refresh();
} }

View File

@ -48,6 +48,16 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
let lastAlertState; let lastAlertState;
let hasAlertRule; let hasAlertRule;
function mouseEnter() {
panelContainer.toggleClass('panel-hover-highlight', true);
ctrl.dashboard.setPanelFocus(ctrl.panel.id);
}
function mouseLeave() {
panelContainer.toggleClass('panel-hover-highlight', false);
ctrl.dashboard.setPanelFocus(0);
}
function resizeScrollableContent() { function resizeScrollableContent() {
if (panelScrollbar) { if (panelScrollbar) {
panelScrollbar.update(); panelScrollbar.update();
@ -98,6 +108,19 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
}); });
}); });
ctrl.events.on('view-mode-changed', () => {
// first wait one pass for dashboard fullscreen view mode to take effect (classses being applied)
setTimeout(() => {
// then recalc style
ctrl.calculatePanelHeight();
// then wait another cycle (this might not be needed)
$timeout(() => {
ctrl.render();
resizeScrollableContent();
});
});
});
// set initial height // set initial height
ctrl.calculatePanelHeight(); ctrl.calculatePanelHeight();
@ -119,7 +142,11 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
panelContainer.removeClass('panel-alert-state--' + lastAlertState); panelContainer.removeClass('panel-alert-state--' + lastAlertState);
} }
if (ctrl.alertState.state === 'ok' || ctrl.alertState.state === 'alerting') { if (
ctrl.alertState.state === 'ok' ||
ctrl.alertState.state === 'alerting' ||
ctrl.alertState.state === 'pending'
) {
panelContainer.addClass('panel-alert-state--' + ctrl.alertState.state); panelContainer.addClass('panel-alert-state--' + ctrl.alertState.state);
} }
@ -170,6 +197,9 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
scope.$apply(ctrl.openInspector.bind(ctrl)); scope.$apply(ctrl.openInspector.bind(ctrl));
}); });
elem.on('mouseenter', mouseEnter);
elem.on('mouseleave', mouseLeave);
scope.$on('$destroy', () => { scope.$on('$destroy', () => {
elem.off(); elem.off();
cornerInfoElem.off(); cornerInfoElem.off();

View File

@ -19,7 +19,7 @@ export class DatasourceSrv {
this.datasources = {}; this.datasources = {};
} }
get(name?): Promise<DataSourceApi> { get(name?: string): Promise<DataSourceApi> {
if (!name) { if (!name) {
return this.get(config.defaultDatasource); return this.get(config.defaultDatasource);
} }
@ -37,7 +37,7 @@ export class DatasourceSrv {
return this.loadDatasource(name); return this.loadDatasource(name);
} }
loadDatasource(name) { loadDatasource(name: string): Promise<DataSourceApi> {
const dsConfig = config.datasources[name]; const dsConfig = config.datasources[name];
if (!dsConfig) { if (!dsConfig) {
return this.$q.reject({ message: 'Datasource named ' + name + ' was not found' }); return this.$q.reject({ message: 'Datasource named ' + name + ' was not found' });

View File

@ -118,7 +118,7 @@ export class DataSourceEditCtrl {
} }
testDatasource() { testDatasource() {
this.datasourceSrv.get(this.current.name).then(datasource => { return this.datasourceSrv.get(this.current.name).then(datasource => {
if (!datasource.testDatasource) { if (!datasource.testDatasource) {
return; return;
} }
@ -126,7 +126,7 @@ export class DataSourceEditCtrl {
this.testing = { done: false, status: 'error' }; this.testing = { done: false, status: 'error' };
// make test call in no backend cache context // make test call in no backend cache context
this.backendSrv return this.backendSrv
.withNoBackendCache(() => { .withNoBackendCache(() => {
return datasource return datasource
.testDatasource() .testDatasource()
@ -161,8 +161,8 @@ export class DataSourceEditCtrl {
return this.backendSrv.put('/api/datasources/' + this.current.id, this.current).then(result => { return this.backendSrv.put('/api/datasources/' + this.current.id, this.current).then(result => {
this.current = result.datasource; this.current = result.datasource;
this.updateNav(); this.updateNav();
this.updateFrontendSettings().then(() => { return this.updateFrontendSettings().then(() => {
this.testDatasource(); return this.testDatasource();
}); });
}); });
} else { } else {

View File

@ -71,15 +71,21 @@
<h3 class="page-heading">Auth</h3> <h3 class="page-heading">Auth</h3>
<div class="gf-form-group"> <div class="gf-form-group">
<div class="gf-form-inline"> <div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Basic Auth" checked="current.basicAuth" label-class="width-10" switch-class="max-width-6"></gf-form-switch> <gf-form-checkbox class="gf-form" label="Basic Auth" checked="current.basicAuth" label-class="width-10" switch-class="max-width-6"></gf-form-checkbox>
<gf-form-switch class="gf-form" label="With Credentials" tooltip="Whether credentials such as cookies or auth headers should be sent with cross-site requests." checked="current.withCredentials" label-class="width-11" switch-class="max-width-6"></gf-form-switch> <gf-form-checkbox class="gf-form" label="With Credentials" tooltip="Whether credentials such as cookies or auth
headers should be sent with cross-site requests." checked="current.withCredentials" label-class="width-11"
switch-class="max-width-6"></gf-form-checkbox>
</div> </div>
<div class="gf-form-inline"> <div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="TLS Client Auth" label-class="width-10" checked="current.jsonData.tlsAuth" switch-class="max-width-6"></gf-form-switch> <gf-form-checkbox class="gf-form" ng-if="current.access=='proxy'" label="TLS Client Auth" label-class="width-10"
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="With CA Cert" tooltip="Needed for verifing self-signed TLS Certs" checked="current.jsonData.tlsAuthWithCACert" label-class="width-11" switch-class="max-width-6"></gf-form-switch> checked="current.jsonData.tlsAuth" switch-class="max-width-6"></gf-form-checkbox>
<gf-form-checkbox class="gf-form" ng-if="current.access=='proxy'" label="With CA Cert" tooltip="Needed for
verifing self-signed TLS Certs" checked="current.jsonData.tlsAuthWithCACert" label-class="width-11"
switch-class="max-width-6"></gf-form-checkbox>
</div> </div>
<div class="gf-form-inline"> <div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="Skip TLS Verify" label-class="width-10" checked="current.jsonData.tlsSkipVerify" switch-class="max-width-6"></gf-form-switch> <gf-form-checkbox class="gf-form" ng-if="current.access=='proxy'" label="Skip TLS Verify" label-class="width-10"
checked="current.jsonData.tlsSkipVerify" switch-class="max-width-6"></gf-form-checkbox>
</div> </div>
</div> </div>

View File

@ -124,7 +124,11 @@ export class TeamGroupSync extends PureComponent<Props, State> {
</button> </button>
<div className="empty-list-cta__pro-tip"> <div className="empty-list-cta__pro-tip">
<i className="fa fa-rocket" /> {headerTooltip} <i className="fa fa-rocket" /> {headerTooltip}
<a className="text-link empty-list-cta__pro-tip-link" href="asd" target="_blank"> <a
className="text-link empty-list-cta__pro-tip-link"
href="http://docs.grafana.org/auth/enhanced_ldap/"
target="_blank"
>
Learn more Learn more
</a> </a>
</div> </div>

View File

@ -74,7 +74,7 @@ export class TeamMembers extends PureComponent<Props, State> {
</td> </td>
<td>{member.login}</td> <td>{member.login}</td>
<td>{member.email}</td> <td>{member.email}</td>
{syncEnabled ? this.renderLabels(member.labels) : null} {syncEnabled && this.renderLabels(member.labels)}
<td className="text-right"> <td className="text-right">
<DeleteButton onConfirmDelete={() => this.onRemoveMember(member)} /> <DeleteButton onConfirmDelete={() => this.onRemoveMember(member)} />
</td> </td>
@ -132,7 +132,7 @@ export class TeamMembers extends PureComponent<Props, State> {
<th /> <th />
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
{syncEnabled ? <th /> : ''} {syncEnabled && <th />}
<th style={{ width: '1%' }} /> <th style={{ width: '1%' }} />
</tr> </tr>
</thead> </thead>

View File

@ -96,7 +96,7 @@ exports[`Render should render component 1`] = `
Sync LDAP or OAuth groups with your Grafana teams. Sync LDAP or OAuth groups with your Grafana teams.
<a <a
className="text-link empty-list-cta__pro-tip-link" className="text-link empty-list-cta__pro-tip-link"
href="asd" href="http://docs.grafana.org/auth/enhanced_ldap/"
target="_blank" target="_blank"
> >
Learn more Learn more

View File

@ -1,10 +1,11 @@
import _ from 'lodash'; import _ from 'lodash';
import * as dateMath from 'app/core/utils/datemath'; import * as dateMath from 'app/core/utils/datemath';
import { LogsStream, LogsModel, makeSeriesForLogs } from 'app/core/logs_model';
import { PluginMeta, DataQuery } from 'app/types';
import LanguageProvider from './language_provider'; import LanguageProvider from './language_provider';
import { mergeStreamsToLogs } from './result_transformer'; import { mergeStreamsToLogs } from './result_transformer';
import { LogsStream, LogsModel, makeSeriesForLogs } from 'app/core/logs_model';
export const DEFAULT_LIMIT = 1000; export const DEFAULT_LIMIT = 1000;
@ -111,6 +112,10 @@ export default class LoggingDatasource {
}); });
} }
async importQueries(queries: DataQuery[], originMeta: PluginMeta): Promise<DataQuery[]> {
return this.languageProvider.importQueries(queries, originMeta.id);
}
metadataRequest(url) { metadataRequest(url) {
// HACK to get label values for {job=|}, will be replaced when implementing LoggingQueryField // HACK to get label values for {job=|}, will be replaced when implementing LoggingQueryField
const apiUrl = url.replace('v1', 'prom'); const apiUrl = url.replace('v1', 'prom');

View File

@ -0,0 +1,74 @@
import Plain from 'slate-plain-serializer';
import LanguageProvider from './language_provider';
describe('Language completion provider', () => {
const datasource = {
metadataRequest: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(0);
});
describe('label suggestions', () => {
it('returns default label suggestions on label context', () => {
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('{}');
const range = value.selection.merge({
anchorOffset: 1,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]);
});
});
});
describe('Query imports', () => {
const datasource = {
metadataRequest: () => ({ data: { data: [] } }),
};
it('returns empty queries for unknown origin datasource', async () => {
const instance = new LanguageProvider(datasource);
const result = await instance.importQueries([{ refId: 'bar', expr: 'foo' }], 'unknown');
expect(result).toEqual([{ refId: 'bar', expr: '' }]);
});
describe('prometheus query imports', () => {
it('returns empty query from metric-only query', async () => {
const instance = new LanguageProvider(datasource);
const result = await instance.importPrometheusQuery('foo');
expect(result).toEqual('');
});
it('returns empty query from selector query if label is not available', async () => {
const datasourceWithLabels = {
metadataRequest: url => (url === '/api/prom/label' ? { data: { data: ['other'] } } : { data: { data: [] } }),
};
const instance = new LanguageProvider(datasourceWithLabels);
const result = await instance.importPrometheusQuery('{foo="bar"}');
expect(result).toEqual('{}');
});
it('returns selector query from selector query with common labels', async () => {
const datasourceWithLabels = {
metadataRequest: url => (url === '/api/prom/label' ? { data: { data: ['foo'] } } : { data: { data: [] } }),
};
const instance = new LanguageProvider(datasourceWithLabels);
const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}');
expect(result).toEqual('{foo="bar"}');
});
});
});

View File

@ -8,9 +8,9 @@ import {
TypeaheadInput, TypeaheadInput,
TypeaheadOutput, TypeaheadOutput,
} from 'app/types/explore'; } from 'app/types/explore';
import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasource/prometheus/language_utils';
import { parseSelector } from 'app/plugins/datasource/prometheus/language_utils';
import PromqlSyntax from 'app/plugins/datasource/prometheus/promql'; import PromqlSyntax from 'app/plugins/datasource/prometheus/promql';
import { DataQuery } from 'app/types';
const DEFAULT_KEYS = ['job', 'namespace']; const DEFAULT_KEYS = ['job', 'namespace'];
const EMPTY_SELECTOR = '{}'; const EMPTY_SELECTOR = '{}';
@ -158,6 +158,56 @@ export default class LoggingLanguageProvider extends LanguageProvider {
return { context, refresher, suggestions }; return { context, refresher, suggestions };
} }
async importQueries(queries: DataQuery[], datasourceType: string): Promise<DataQuery[]> {
if (datasourceType === 'prometheus') {
return Promise.all(
queries.map(async query => {
const expr = await this.importPrometheusQuery(query.expr);
return {
...query,
expr,
};
})
);
}
return queries.map(query => ({
...query,
expr: '',
}));
}
async importPrometheusQuery(query: string): Promise<string> {
// Consider only first selector in query
const selectorMatch = query.match(selectorRegexp);
if (selectorMatch) {
const selector = selectorMatch[0];
const labels = {};
selector.replace(labelRegexp, (_, key, operator, value) => {
labels[key] = { value, operator };
return '';
});
// Keep only labels that exist on origin and target datasource
await this.start(); // fetches all existing label keys
const commonLabels = {};
for (const key in labels) {
const existingKeys = this.labelKeys[EMPTY_SELECTOR];
if (existingKeys.indexOf(key) > -1) {
// Should we check for label value equality here?
commonLabels[key] = labels[key];
}
}
const labelKeys = Object.keys(commonLabels).sort();
const cleanSelector = labelKeys
.map(key => `${key}${commonLabels[key].operator}${commonLabels[key].value}`)
.join(',');
return ['{', cleanSelector, '}'].join('');
}
return '';
}
async fetchLogLabels() { async fetchLogLabels() {
const url = '/api/prom/label'; const url = '/api/prom/label';
try { try {

View File

@ -10,6 +10,7 @@ import { BackendSrv } from 'app/core/services/backend_srv';
import addLabelToQuery from './add_label_to_query'; import addLabelToQuery from './add_label_to_query';
import { getQueryHints } from './query_hints'; import { getQueryHints } from './query_hints';
import { expandRecordingRules } from './language_utils';
export function alignRange(start, end, step) { export function alignRange(start, end, step) {
const alignedEnd = Math.ceil(end / step) * step; const alignedEnd = Math.ceil(end / step) * step;
@ -468,11 +469,8 @@ export class PrometheusDatasource {
return `sum(${query.trim()}) by ($1)`; return `sum(${query.trim()}) by ($1)`;
} }
case 'EXPAND_RULES': { case 'EXPAND_RULES': {
const mapping = action.mapping; if (action.mapping) {
if (mapping) { return expandRecordingRules(query, action.mapping);
const ruleNames = Object.keys(mapping);
const rulesRegex = new RegExp(`(\\s|^)(${ruleNames.join('|')})(\\s|$|\\()`, 'ig');
return query.replace(rulesRegex, (match, pre, name, post) => mapping[name]);
} }
} }
default: default:

View File

@ -78,9 +78,24 @@ export default class PromQlLanguageProvider extends LanguageProvider {
}; };
// Keep this DOM-free for testing // Keep this DOM-free for testing
provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput { provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput {
// Local text properties
const empty = value.document.text.length === 0;
const selectedLines = value.document.getTextsAtRangeAsArray(value.selection);
const currentLine = selectedLines.length === 1 ? selectedLines[0] : null;
const nextCharacter = currentLine ? currentLine.text[value.selection.anchorOffset] : null;
// Syntax spans have 3 classes by default. More indicate a recognized token // Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3; const tokenRecognized = wrapperClasses.length > 3;
// Non-empty prefix, but not inside known token
const prefixUnrecognized = prefix && !tokenRecognized;
// Prevent suggestions in `function(|suffix)`
const noSuffix = !nextCharacter || nextCharacter === ')';
// Empty prefix is safe if it does not immediately folllow a complete expression and has no text after it
const safeEmptyPrefix = prefix === '' && !text.match(/^[\]})\s]+$/) && noSuffix;
// About to type next operand if preceded by binary operator
const isNextOperand = text.match(/[+\-*/^%]/);
// Determine candidates by CSS context // Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) { if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|] // Suggestions for metric[|]
@ -89,14 +104,14 @@ export default class PromQlLanguageProvider extends LanguageProvider {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelCompletionItems.apply(this, arguments); return this.getLabelCompletionItems.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) { } else if (_.includes(wrapperClasses, 'context-aggregation')) {
// Suggestions for sum(metric) by (|)
return this.getAggregationCompletionItems.apply(this, arguments); return this.getAggregationCompletionItems.apply(this, arguments);
} else if ( } else if (empty) {
// Show default suggestions in a couple of scenarios // Suggestions for empty query field
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyCompletionItems(context || {}); return this.getEmptyCompletionItems(context || {});
} else if (prefixUnrecognized || safeEmptyPrefix || isNextOperand) {
// Show term suggestions in a couple of scenarios
return this.getTermCompletionItems();
} }
return { return {
@ -106,8 +121,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
getEmptyCompletionItems(context: any): TypeaheadOutput { getEmptyCompletionItems(context: any): TypeaheadOutput {
const { history } = context; const { history } = context;
const { metrics } = this; let suggestions: CompletionItemGroup[] = [];
const suggestions: CompletionItemGroup[] = [];
if (history && history.length > 0) { if (history && history.length > 0) {
const historyItems = _.chain(history) const historyItems = _.chain(history)
@ -126,13 +140,23 @@ export default class PromQlLanguageProvider extends LanguageProvider {
}); });
} }
const termCompletionItems = this.getTermCompletionItems();
suggestions = [...suggestions, ...termCompletionItems.suggestions];
return { suggestions };
}
getTermCompletionItems(): TypeaheadOutput {
const { metrics } = this;
const suggestions: CompletionItemGroup[] = [];
suggestions.push({ suggestions.push({
prefixMatch: true, prefixMatch: true,
label: 'Functions', label: 'Functions',
items: FUNCTIONS.map(setFunctionKind), items: FUNCTIONS.map(setFunctionKind),
}); });
if (metrics) { if (metrics && metrics.length > 0) {
suggestions.push({ suggestions.push({
label: 'Metrics', label: 'Metrics',
items: metrics.map(wrapLabel), items: metrics.map(wrapLabel),

View File

@ -24,8 +24,8 @@ export function processLabels(labels, withName = false) {
} }
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/; // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
const selectorRegexp = /\{[^}]*?\}/; export const selectorRegexp = /\{[^}]*?\}/;
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g; export const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } { export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
if (!query.match(selectorRegexp)) { if (!query.match(selectorRegexp)) {
// Special matcher for metrics // Special matcher for metrics
@ -83,3 +83,9 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any
return { labelKeys, selector: selectorString }; return { labelKeys, selector: selectorString };
} }
export function expandRecordingRules(query: string, mapping: { [name: string]: string }): string {
const ruleNames = Object.keys(mapping);
const rulesRegex = new RegExp(`(\\s|^)(${ruleNames.join('|')})(\\s|$|\\(|\\[|\\{)`, 'ig');
return query.replace(rulesRegex, (match, pre, name, post) => `${pre}${mapping[name]}${post}`);
}

View File

@ -7,18 +7,47 @@ describe('Language completion provider', () => {
metadataRequest: () => ({ data: { data: [] } }), metadataRequest: () => ({ data: { data: [] } }),
}; };
describe('empty query suggestions', () => {
it('returns default suggestions on emtpty context', () => { it('returns default suggestions on emtpty context', () => {
const instance = new LanguageProvider(datasource); const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] }); const value = Plain.deserialize('');
const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
expect(result.context).toBeUndefined(); expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined(); expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2); expect(result.suggestions).toMatchObject([
{
label: 'Functions',
},
]);
});
it('returns default suggestions with metrics on emtpty context when metrics were provided', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const value = Plain.deserialize('');
const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toMatchObject([
{
label: 'Functions',
},
{
label: 'Metrics',
},
]);
});
}); });
describe('range suggestions', () => { describe('range suggestions', () => {
it('returns range suggestions in range context', () => { it('returns range suggestions in range context', () => {
const instance = new LanguageProvider(datasource); const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '1', prefix: '1', wrapperClasses: ['context-range'] }); const value = Plain.deserialize('1');
const result = instance.provideCompletionItems({
text: '1',
prefix: '1',
value,
wrapperClasses: ['context-range'],
});
expect(result.context).toBe('context-range'); expect(result.context).toBe('context-range');
expect(result.refresher).toBeUndefined(); expect(result.refresher).toBeUndefined();
expect(result.suggestions).toEqual([ expect(result.suggestions).toEqual([
@ -31,20 +60,54 @@ describe('Language completion provider', () => {
}); });
describe('metric suggestions', () => { describe('metric suggestions', () => {
it('returns metrics suggestions by default', () => { it('returns metrics and function suggestions in an unknown context', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', wrapperClasses: [] }); const value = Plain.deserialize('a');
const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', value, wrapperClasses: [] });
expect(result.context).toBeUndefined(); expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined(); expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2); expect(result.suggestions).toMatchObject([
{
label: 'Functions',
},
{
label: 'Metrics',
},
]);
}); });
it('returns default suggestions after a binary operator', () => { it('returns metrics and function suggestions after a binary operator', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] }); const value = Plain.deserialize('*');
const result = instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] });
expect(result.context).toBeUndefined(); expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined(); expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2); expect(result.suggestions).toMatchObject([
{
label: 'Functions',
},
{
label: 'Metrics',
},
]);
});
it('returns no suggestions at the beginning of a non-empty function', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const value = Plain.deserialize('sum(up)');
const range = value.selection.merge({
anchorOffset: 4,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
value: valueWithSelection,
wrapperClasses: [],
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(0);
}); });
}); });

View File

@ -1,4 +1,4 @@
import { parseSelector } from '../language_utils'; import { expandRecordingRules, parseSelector } from '../language_utils';
describe('parseSelector()', () => { describe('parseSelector()', () => {
let parsed; let parsed;
@ -62,3 +62,25 @@ describe('parseSelector()', () => {
expect(parsed.selector).toBe('{__name__="bar:metric:1m"}'); expect(parsed.selector).toBe('{__name__="bar:metric:1m"}');
}); });
}); });
describe('expandRecordingRules()', () => {
it('returns query w/o recording rules as is', () => {
expect(expandRecordingRules('metric', {})).toBe('metric');
expect(expandRecordingRules('metric + metric', {})).toBe('metric + metric');
expect(expandRecordingRules('metric{}', {})).toBe('metric{}');
});
it('does not modify recording rules name in label values', () => {
expect(expandRecordingRules('{__name__="metric"} + bar', { metric: 'foo', bar: 'super' })).toBe(
'{__name__="metric"} + super'
);
});
it('returns query with expanded recording rules', () => {
expect(expandRecordingRules('metric', { metric: 'foo' })).toBe('foo');
expect(expandRecordingRules('metric + metric', { metric: 'foo' })).toBe('foo + foo');
expect(expandRecordingRules('metric{}', { metric: 'foo' })).toBe('foo{}');
expect(expandRecordingRules('metric[]', { metric: 'foo' })).toBe('foo[]');
expect(expandRecordingRules('metric + foo', { metric: 'foo', foo: 'bar' })).toBe('foo + bar');
});
});

View File

@ -50,5 +50,6 @@
<gf-form-switch class="gf-form" label="No data" label-class="width-10" checked="ctrl.stateFilter['no_data']" on-change="ctrl.updateStateFilter()"></gf-form-switch> <gf-form-switch class="gf-form" label="No data" label-class="width-10" checked="ctrl.stateFilter['no_data']" on-change="ctrl.updateStateFilter()"></gf-form-switch>
<gf-form-switch class="gf-form" label="Execution error" label-class="width-10" checked="ctrl.stateFilter['execution_error']" on-change="ctrl.updateStateFilter()"></gf-form-switch> <gf-form-switch class="gf-form" label="Execution error" label-class="width-10" checked="ctrl.stateFilter['execution_error']" on-change="ctrl.updateStateFilter()"></gf-form-switch>
<gf-form-switch class="gf-form" label="Alerting" label-class="width-10" checked="ctrl.stateFilter['alerting']" on-change="ctrl.updateStateFilter()"></gf-form-switch> <gf-form-switch class="gf-form" label="Alerting" label-class="width-10" checked="ctrl.stateFilter['alerting']" on-change="ctrl.updateStateFilter()"></gf-form-switch>
<gf-form-switch class="gf-form" label="Pending" label-class="width-10" checked="ctrl.stateFilter['pending']" on-change="ctrl.updateStateFilter()"></gf-form-switch>
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@ import { tickStep } from 'app/core/utils/ticks';
import { appEvents, coreModule, updateLegendValues } from 'app/core/core'; import { appEvents, coreModule, updateLegendValues } from 'app/core/core';
import GraphTooltip from './graph_tooltip'; import GraphTooltip from './graph_tooltip';
import { ThresholdManager } from './threshold_manager'; import { ThresholdManager } from './threshold_manager';
import { TimeRegionManager } from './time_region_manager';
import { EventManager } from 'app/features/annotations/all'; import { EventManager } from 'app/features/annotations/all';
import { convertToHistogramData } from './histogram'; import { convertToHistogramData } from './histogram';
import { alignYLevel } from './align_yaxes'; import { alignYLevel } from './align_yaxes';
@ -38,6 +39,7 @@ class GraphElement {
panelWidth: number; panelWidth: number;
eventManager: EventManager; eventManager: EventManager;
thresholdManager: ThresholdManager; thresholdManager: ThresholdManager;
timeRegionManager: TimeRegionManager;
legendElem: HTMLElement; legendElem: HTMLElement;
constructor(private scope, private elem, private timeSrv) { constructor(private scope, private elem, private timeSrv) {
@ -49,6 +51,7 @@ class GraphElement {
this.panelWidth = 0; this.panelWidth = 0;
this.eventManager = new EventManager(this.ctrl); this.eventManager = new EventManager(this.ctrl);
this.thresholdManager = new ThresholdManager(this.ctrl); this.thresholdManager = new ThresholdManager(this.ctrl);
this.timeRegionManager = new TimeRegionManager(this.ctrl);
this.tooltip = new GraphTooltip(this.elem, this.ctrl.dashboard, this.scope, () => { this.tooltip = new GraphTooltip(this.elem, this.ctrl.dashboard, this.scope, () => {
return this.sortedSeries; return this.sortedSeries;
}); });
@ -125,6 +128,7 @@ class GraphElement {
onPanelTeardown() { onPanelTeardown() {
this.thresholdManager = null; this.thresholdManager = null;
this.timeRegionManager = null;
if (this.plot) { if (this.plot) {
this.plot.destroy(); this.plot.destroy();
@ -215,6 +219,7 @@ class GraphElement {
} }
this.thresholdManager.draw(plot); this.thresholdManager.draw(plot);
this.timeRegionManager.draw(plot);
} }
processOffsetHook(plot, gridMargin) { processOffsetHook(plot, gridMargin) {
@ -293,6 +298,7 @@ class GraphElement {
this.prepareXAxis(options, this.panel); this.prepareXAxis(options, this.panel);
this.configureYAxisOptions(this.data, options); this.configureYAxisOptions(this.data, options);
this.thresholdManager.addFlotOptions(options, this.panel); this.thresholdManager.addFlotOptions(options, this.panel);
this.timeRegionManager.addFlotOptions(options, this.panel);
this.eventManager.addFlotEvents(this.annotations, options); this.eventManager.addFlotEvents(this.annotations, options);
this.sortedSeries = this.sortSeries(this.data, this.panel); this.sortedSeries = this.sortSeries(this.data, this.panel);

View File

@ -1,6 +1,7 @@
import './graph'; import './graph';
import './series_overrides_ctrl'; import './series_overrides_ctrl';
import './thresholds_form'; import './thresholds_form';
import './time_regions_form';
import template from './template'; import template from './template';
import _ from 'lodash'; import _ from 'lodash';
@ -111,6 +112,7 @@ class GraphCtrl extends MetricsPanelCtrl {
// other style overrides // other style overrides
seriesOverrides: [], seriesOverrides: [],
thresholds: [], thresholds: [],
timeRegions: [],
}; };
/** @ngInject */ /** @ngInject */
@ -133,9 +135,9 @@ class GraphCtrl extends MetricsPanelCtrl {
} }
onInitEditMode() { onInitEditMode() {
this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4);
this.addEditorTab('Axes', axesEditorComponent, 2); this.addEditorTab('Axes', axesEditorComponent, 2);
this.addEditorTab('Legend', 'public/app/plugins/panel/graph/tab_legend.html', 3); this.addEditorTab('Legend', 'public/app/plugins/panel/graph/tab_legend.html', 3);
this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4);
// if (config.alertingEnabled) { // if (config.alertingEnabled) {
// this.addEditorTab('Alert', alertTab, 5); // this.addEditorTab('Alert', alertTab, 5);

View File

@ -0,0 +1,262 @@
import { TimeRegionManager, colorModes } from '../time_region_manager';
import moment from 'moment';
describe('TimeRegionManager', () => {
function plotOptionsScenario(desc, func) {
describe(desc, () => {
const ctx: any = {
panel: {
timeRegions: [],
},
options: {
grid: { markings: [] },
},
panelCtrl: {
range: {},
dashboard: {
isTimezoneUtc: () => false,
},
},
};
ctx.setup = (regions, from, to) => {
ctx.panel.timeRegions = regions;
ctx.panelCtrl.range.from = from;
ctx.panelCtrl.range.to = to;
const manager = new TimeRegionManager(ctx.panelCtrl);
manager.addFlotOptions(ctx.options, ctx.panel);
};
ctx.printScenario = () => {
console.log(
`Time range: from=${ctx.panelCtrl.range.from.format()}, to=${ctx.panelCtrl.range.to.format()}`,
ctx.panelCtrl.range.from._isUTC
);
ctx.options.grid.markings.forEach((m, i) => {
console.log(
`Marking (${i}): from=${moment(m.xaxis.from).format()}, to=${moment(m.xaxis.to).format()}, color=${m.color}`
);
});
};
func(ctx);
});
}
describe('When creating plot markings using local time', () => {
plotOptionsScenario('for day of week region', ctx => {
const regions = [{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, colorMode: 'red' }];
const from = moment('2018-01-01T00:00:00+01:00');
const to = moment('2018-01-01T23:59:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add fill', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-01-01T01:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-01-02T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
});
it('should add line before', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-01-01T01:00:00+01:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-01-01T01:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.line);
});
it('should add line after', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-01-02T00:59:59+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-01-02T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.line);
});
});
plotOptionsScenario('for time from region', ctx => {
const regions = [{ from: '05:00', fill: true, colorMode: 'red' }];
const from = moment('2018-01-01T00:00+01:00');
const to = moment('2018-01-03T23:59+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at 05:00 each day', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-01-01T06:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-01-01T06:00:00+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-01-02T06:00:00+01:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-01-02T06:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-01-03T06:00:00+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-01-03T06:00:00+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for time to region', ctx => {
const regions = [{ to: '05:00', fill: true, colorMode: 'red' }];
const from = moment('2018-02-01T00:00+01:00');
const to = moment('2018-02-03T23:59+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at 05:00 each day', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-02-01T06:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-02-01T06:00:00+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-02-02T06:00:00+01:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-02-02T06:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-02-03T06:00:00+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-02-03T06:00:00+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week from/to region', ctx => {
const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = moment('2018-01-01T18:45:05+01:00');
const to = moment('2018-01-22T08:27:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-01-07T01:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-01-08T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-01-14T01:00:00+01:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-01-15T00:59:59+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-01-21T01:00:00+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-01-22T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week from region', ctx => {
const regions = [{ fromDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = moment('2018-01-01T18:45:05+01:00');
const to = moment('2018-01-22T08:27:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-01-07T01:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-01-08T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-01-14T01:00:00+01:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-01-15T00:59:59+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-01-21T01:00:00+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-01-22T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week to region', ctx => {
const regions = [{ toDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = moment('2018-01-01T18:45:05+01:00');
const to = moment('2018-01-22T08:27:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-01-07T01:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-01-08T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-01-14T01:00:00+01:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-01-15T00:59:59+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-01-21T01:00:00+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-01-22T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week from/to time region with daylight saving time', ctx => {
const regions = [{ fromDayOfWeek: 7, from: '20:00', toDayOfWeek: 7, to: '23:00', fill: true, colorMode: 'red' }];
const from = moment('2018-03-17T06:00:00+01:00');
const to = moment('2018-04-03T06:00:00+02:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday between 20:00 and 23:00', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-03-18T21:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-03-19T00:00:00+01:00').format());
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-03-25T22:00:00+02:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-03-26T01:00:00+02:00').format());
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-04-01T22:00:00+02:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-04-02T01:00:00+02:00').format());
});
});
plotOptionsScenario('for each day of week with winter time', ctx => {
const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = moment('2018-10-20T14:50:11+02:00');
const to = moment('2018-11-07T12:56:23+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-10-21T02:00:00+02:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-10-22T01:59:59+02:00').format());
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-10-28T02:00:00+02:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-10-29T00:59:59+01:00').format());
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-11-04T01:00:00+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-11-05T00:59:59+01:00').format());
});
});
});
});

View File

@ -14,6 +14,11 @@
Thresholds <span class="muted">({{ctrl.panel.thresholds.length}})</span> Thresholds <span class="muted">({{ctrl.panel.thresholds.length}})</span>
</a> </a>
</li> </li>
<li ng-class="{active: ctrl.subTabIndex === 3}">
<a ng-click="ctrl.subTabIndex = 3">
Time regions <span class="muted">({{ctrl.panel.timeRegions.length}})</span>
</a>
</li>
</ul> </ul>
</aside> </aside>
@ -132,4 +137,8 @@
<graph-threshold-form panel-ctrl="ctrl"></graph-threshold-form> <graph-threshold-form panel-ctrl="ctrl"></graph-threshold-form>
</div> </div>
<div class="edit-tab-content" ng-if="ctrl.subTabIndex === 3">
<graph-time-region-form panel-ctrl="ctrl"></graph-time-region-form>
</div>
</div> </div>

View File

@ -0,0 +1,77 @@
<div class="gf-form-group">
<h5>Thresholds</h5>
<p class="muted" ng-show="ctrl.disabled">
Visual thresholds options <strong>disabled.</strong>
Visit the Alert tab update your thresholds. <br>
To re-enable thresholds, the alert rule must be deleted from this panel.
</p>
<div ng-class="{'thresholds-form-disabled': ctrl.disabled}">
<div class="gf-form-inline" ng-repeat="threshold in ctrl.panel.thresholds">
<div class="gf-form">
<label class="gf-form-label">T{{$index+1}}</label>
</div>
<div class="gf-form">
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="threshold.op"
ng-options="f for f in ['gt', 'lt']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled"></select>
</div>
<input type="number" ng-model="threshold.value" class="gf-form-input width-8"
ng-change="ctrl.render()" placeholder="value" ng-disabled="ctrl.disabled">
</div>
<div class="gf-form">
<label class="gf-form-label">Color</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="threshold.colorMode"
ng-options="f for f in ['custom', 'critical', 'warning', 'ok']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled">
</select>
</div>
</div>
<gf-form-switch class="gf-form" label="Fill" checked="threshold.fill"
on-change="ctrl.render()" ng-disabled="ctrl.disabled"></gf-form-switch>
<div class="gf-form" ng-if="threshold.fill && threshold.colorMode === 'custom'">
<label class="gf-form-label">Fill color</label>
<span class="gf-form-label">
<color-picker color="threshold.fillColor" onChange="ctrl.onFillColorChange($index)"></color-picker>
</span>
</div>
<gf-form-switch class="gf-form" label="Line" checked="threshold.line"
on-change="ctrl.render()" ng-disabled="ctrl.disabled"></gf-form-switch>
<div class="gf-form" ng-if="threshold.line && threshold.colorMode === 'custom'">
<label class="gf-form-label">Line color</label>
<span class="gf-form-label">
<color-picker color="threshold.lineColor" onChange="ctrl.onLineColorChange($index)"></color-picker>
</span>
</div>
<div class="gf-form">
<label class="gf-form-label">Y-Axis</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="threshold.yaxis"
ng-init="threshold.yaxis = threshold.yaxis === 'left' || threshold.yaxis === 'right' ? threshold.yaxis : 'left'"
ng-options="f for f in ['left', 'right']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="ctrl.removeThreshold($index)" ng-disabled="ctrl.disabled">
<i class="fa fa-trash"></i>
</a>
</label>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.addThreshold()" ng-disabled="ctrl.disabled">
<i class="fa fa-plus"></i>&nbsp;Add Threshold
</button>
</div>
</div>
</div>

View File

@ -58,90 +58,10 @@ export class ThresholdFormCtrl {
} }
} }
const template = `
<div class="gf-form-group">
<h5>Thresholds</h5>
<p class="muted" ng-show="ctrl.disabled">
Visual thresholds options <strong>disabled.</strong>
Visit the Alert tab update your thresholds. <br>
To re-enable thresholds, the alert rule must be deleted from this panel.
</p>
<div ng-class="{'thresholds-form-disabled': ctrl.disabled}">
<div class="gf-form-inline" ng-repeat="threshold in ctrl.panel.thresholds">
<div class="gf-form">
<label class="gf-form-label">T{{$index+1}}</label>
</div>
<div class="gf-form">
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="threshold.op"
ng-options="f for f in ['gt', 'lt']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled"></select>
</div>
<input type="number" ng-model="threshold.value" class="gf-form-input width-8"
ng-change="ctrl.render()" placeholder="value" ng-disabled="ctrl.disabled">
</div>
<div class="gf-form">
<label class="gf-form-label">Color</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="threshold.colorMode"
ng-options="f for f in ['custom', 'critical', 'warning', 'ok']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled">
</select>
</div>
</div>
<gf-form-switch class="gf-form" label="Fill" checked="threshold.fill"
on-change="ctrl.render()" ng-disabled="ctrl.disabled"></gf-form-switch>
<div class="gf-form" ng-if="threshold.fill && threshold.colorMode === 'custom'">
<label class="gf-form-label">Fill color</label>
<span class="gf-form-label">
<color-picker color="threshold.fillColor" onChange="ctrl.onFillColorChange($index)"></color-picker>
</span>
</div>
<gf-form-switch class="gf-form" label="Line" checked="threshold.line"
on-change="ctrl.render()" ng-disabled="ctrl.disabled"></gf-form-switch>
<div class="gf-form" ng-if="threshold.line && threshold.colorMode === 'custom'">
<label class="gf-form-label">Line color</label>
<span class="gf-form-label">
<color-picker color="threshold.lineColor" onChange="ctrl.onLineColorChange($index)"></color-picker>
</span>
</div>
<div class="gf-form">
<label class="gf-form-label">Y-Axis</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="threshold.yaxis"
ng-init="threshold.yaxis = threshold.yaxis === 'left' || threshold.yaxis === 'right' ? threshold.yaxis : 'left'"
ng-options="f for f in ['left', 'right']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="ctrl.removeThreshold($index)" ng-disabled="ctrl.disabled">
<i class="fa fa-trash"></i>
</a>
</label>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.addThreshold()" ng-disabled="ctrl.disabled">
<i class="fa fa-plus"></i>&nbsp;Add Threshold
</button>
</div>
</div>
</div>
`;
coreModule.directive('graphThresholdForm', () => { coreModule.directive('graphThresholdForm', () => {
return { return {
restrict: 'E', restrict: 'E',
template: template, templateUrl: 'public/app/plugins/panel/graph/thresholds_form.html',
controller: ThresholdFormCtrl, controller: ThresholdFormCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',

View File

@ -0,0 +1,248 @@
import 'vendor/flot/jquery.flot';
import _ from 'lodash';
import moment from 'moment';
import config from 'app/core/config';
export const colorModes = {
gray: {
themeDependent: true,
title: 'Gray',
darkColor: { fill: 'rgba(255, 255, 255, 0.09)', line: 'rgba(255, 255, 255, 0.2)' },
lightColor: { fill: 'rgba(0, 0, 0, 0.09)', line: 'rgba(0, 0, 0, 0.2)' },
},
red: {
title: 'Red',
color: { fill: 'rgba(234, 112, 112, 0.12)', line: 'rgba(237, 46, 24, 0.60)' },
},
green: {
title: 'Green',
color: { fill: 'rgba(11, 237, 50, 0.090)', line: 'rgba(6,163,69, 0.60)' },
},
blue: {
title: 'Blue',
color: { fill: 'rgba(11, 125, 238, 0.12)', line: 'rgba(11, 125, 238, 0.60)' },
},
yellow: {
title: 'Yellow',
color: { fill: 'rgba(235, 138, 14, 0.12)', line: 'rgba(247, 149, 32, 0.60)' },
},
custom: { title: 'Custom' },
};
export function getColorModes() {
return _.map(Object.keys(colorModes), key => {
return {
key: key,
value: colorModes[key].title,
};
});
}
function getColor(timeRegion) {
if (Object.keys(colorModes).indexOf(timeRegion.colorMode) === -1) {
timeRegion.colorMode = 'red';
}
if (timeRegion.colorMode === 'custom') {
return {
fill: timeRegion.fillColor,
line: timeRegion.lineColor,
};
}
const colorMode = colorModes[timeRegion.colorMode];
if (colorMode.themeDependent === true) {
return config.bootData.user.lightTheme ? colorMode.lightColor : colorMode.darkColor;
}
return colorMode.color;
}
export class TimeRegionManager {
plot: any;
timeRegions: any;
constructor(private panelCtrl) {}
draw(plot) {
this.timeRegions = this.panelCtrl.panel.timeRegions;
this.plot = plot;
}
addFlotOptions(options, panel) {
if (!panel.timeRegions || panel.timeRegions.length === 0) {
return;
}
const tRange = { from: moment(this.panelCtrl.range.from).utc(), to: moment(this.panelCtrl.range.to).utc() };
let i, hRange, timeRegion, regions, fromStart, fromEnd, timeRegionColor;
const timeRegionsCopy = panel.timeRegions.map(a => ({ ...a }));
for (i = 0; i < timeRegionsCopy.length; i++) {
timeRegion = timeRegionsCopy[i];
if (!(timeRegion.fromDayOfWeek || timeRegion.from) && !(timeRegion.toDayOfWeek || timeRegion.to)) {
continue;
}
hRange = {
from: this.parseTimeRange(timeRegion.from),
to: this.parseTimeRange(timeRegion.to),
};
if (!timeRegion.fromDayOfWeek && timeRegion.toDayOfWeek) {
timeRegion.fromDayOfWeek = timeRegion.toDayOfWeek;
}
if (!timeRegion.toDayOfWeek && timeRegion.fromDayOfWeek) {
timeRegion.toDayOfWeek = timeRegion.fromDayOfWeek;
}
if (timeRegion.fromDayOfWeek) {
hRange.from.dayOfWeek = Number(timeRegion.fromDayOfWeek);
}
if (timeRegion.toDayOfWeek) {
hRange.to.dayOfWeek = Number(timeRegion.toDayOfWeek);
}
if (!hRange.from.h && hRange.to.h) {
hRange.from = hRange.to;
}
if (hRange.from.h && !hRange.to.h) {
hRange.to = hRange.from;
}
if (hRange.from.dayOfWeek && !hRange.from.h && !hRange.from.m) {
hRange.from.h = 0;
hRange.from.m = 0;
hRange.from.s = 0;
}
if (hRange.to.dayOfWeek && !hRange.to.h && !hRange.to.m) {
hRange.to.h = 23;
hRange.to.m = 59;
hRange.to.s = 59;
}
if (!hRange.from || !hRange.to) {
continue;
}
regions = [];
if (
hRange.from.h >= tRange.from.hour() &&
hRange.from.h <= tRange.from.hour() &&
hRange.from.m >= tRange.from.minute() &&
hRange.from.m <= tRange.from.minute() &&
hRange.to.h >= tRange.to.hour() &&
hRange.to.h <= tRange.to.hour() &&
hRange.to.m >= tRange.to.minute() &&
hRange.to.m <= tRange.to.minute()
) {
regions.push({ from: tRange.from.valueOf(), to: tRange.to.startOf('hour').valueOf() });
} else {
fromStart = moment(tRange.from);
fromStart.set('hour', 0);
fromStart.set('minute', 0);
fromStart.set('second', 0);
fromStart.add(hRange.from.h, 'hours');
fromStart.add(hRange.from.m, 'minutes');
fromStart.add(hRange.from.s, 'seconds');
while (fromStart.unix() <= tRange.to.unix()) {
while (hRange.from.dayOfWeek && hRange.from.dayOfWeek !== fromStart.isoWeekday()) {
fromStart.add(24, 'hours');
}
if (fromStart.unix() > tRange.to.unix()) {
break;
}
fromEnd = moment(fromStart);
if (hRange.from.h <= hRange.to.h) {
fromEnd.add(hRange.to.h - hRange.from.h, 'hours');
} else if (hRange.from.h + hRange.to.h < 23) {
fromEnd.add(hRange.to.h, 'hours');
} else {
fromEnd.add(24 - hRange.from.h, 'hours');
}
fromEnd.set('minute', hRange.to.m);
fromEnd.set('second', hRange.to.s);
while (hRange.to.dayOfWeek && hRange.to.dayOfWeek !== fromEnd.isoWeekday()) {
fromEnd.add(24, 'hours');
}
const outsideRange =
(fromStart.unix() < tRange.from.unix() && fromEnd.unix() < tRange.from.unix()) ||
(fromStart.unix() > tRange.to.unix() && fromEnd.unix() > tRange.to.unix());
if (!outsideRange) {
regions.push({ from: fromStart.valueOf(), to: fromEnd.valueOf() });
}
fromStart.add(24, 'hours');
}
}
timeRegionColor = getColor(timeRegion);
for (let j = 0; j < regions.length; j++) {
const r = regions[j];
if (timeRegion.fill) {
options.grid.markings.push({
xaxis: { from: r.from, to: r.to },
color: timeRegionColor.fill,
});
}
if (timeRegion.line) {
options.grid.markings.push({
xaxis: { from: r.from, to: r.from },
color: timeRegionColor.line,
});
options.grid.markings.push({
xaxis: { from: r.to, to: r.to },
color: timeRegionColor.line,
});
}
}
}
}
parseTimeRange(str) {
const timeRegex = /^([\d]+):?(\d{2})?/;
const result = { h: null, m: null };
const match = timeRegex.exec(str);
if (!match) {
return result;
}
if (match.length > 1) {
result.h = Number(match[1]);
result.m = 0;
if (match.length > 2 && match[2] !== undefined) {
result.m = Number(match[2]);
}
if (result.h > 23) {
result.h = 23;
}
if (result.m > 59) {
result.m = 59;
}
}
return result;
}
}

View File

@ -0,0 +1,64 @@
<div class="gf-form-group">
<h5>Time regions <tip>All configured time regions refers to UTC time</tip></h5>
<div class="gf-form-inline" ng-repeat="timeRegion in ctrl.panel.timeRegions">
<div class="gf-form">
<label class="gf-form-label">T{{$index+1}}</label>
</div>
<div class="gf-form">
<label class="gf-form-label">From</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input width-6" ng-model="timeRegion.fromDayOfWeek" ng-options="f.d as f.value for f in [{d: undefined, value: 'Any'}, {d:1, value: 'Mon'}, {d:2, value: 'Tue'}, {d:3, value: 'Wed'}, {d:4, value: 'Thu'}, {d:5, value: 'Fri'}, {d:6, value: 'Sat'}, {d:7, value: 'Sun'}]"
ng-change="ctrl.render()"></select>
</div>
<input type="text" ng-maxlength="5" ng-model="timeRegion.from" class="gf-form-input width-5" ng-change="ctrl.render()" placeholder="hh:mm">
<label class="gf-form-label">To</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input width-6" ng-model="timeRegion.toDayOfWeek" ng-options="f.d as f.value for f in [{d: undefined, value: 'Any'}, {d:1, value: 'Mon'}, {d:2, value: 'Tue'}, {d:3, value: 'Wed'}, {d:4, value: 'Thu'}, {d:5, value: 'Fri'}, {d:6, value: 'Sat'}, {d:7, value: 'Sun'}]"
ng-change="ctrl.render()"></select>
</div>
<input type="text" ng-maxlength="5" ng-model="timeRegion.to" class="gf-form-input width-5" ng-change="ctrl.render()" placeholder="hh:mm"
>
</div>
<div class="gf-form">
<label class="gf-form-label">Color</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="timeRegion.colorMode" ng-options="f.key as f.value for f in ctrl.colorModes" ng-change="ctrl.render()">
</select>
</div>
</div>
<gf-form-switch class="gf-form" label="Fill" checked="timeRegion.fill" on-change="ctrl.render()"></gf-form-switch>
<div class="gf-form" ng-if="timeRegion.fill && timeRegion.colorMode === 'custom'">
<label class="gf-form-label">Fill color</label>
<span class="gf-form-label">
<color-picker color="timeRegion.fillColor" onChange="ctrl.onFillColorChange($index)"></color-picker>
</span>
</div>
<gf-form-switch class="gf-form" label="Line" checked="timeRegion.line" on-change="ctrl.render()"></gf-form-switch>
<div class="gf-form" ng-if="timeRegion.line && timeRegion.colorMode === 'custom'">
<label class="gf-form-label">Line color</label>
<span class="gf-form-label">
<color-picker color="timeRegion.lineColor" onChange="ctrl.onLineColorChange($index)"></color-picker>
</span>
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="ctrl.removeTimeRegion($index)">
<i class="fa fa-trash"></i>
</a>
</label>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.addTimeRegion()">
<i class="fa fa-plus"></i>&nbsp;Add time region
</button>
</div>
</div>

View File

@ -0,0 +1,73 @@
import coreModule from 'app/core/core_module';
import { getColorModes } from './time_region_manager';
export class TimeRegionFormCtrl {
panelCtrl: any;
panel: any;
disabled: boolean;
colorModes: any;
/** @ngInject */
constructor($scope) {
this.panel = this.panelCtrl.panel;
const unbindDestroy = $scope.$on('$destroy', () => {
this.panelCtrl.editingTimeRegions = false;
this.panelCtrl.render();
unbindDestroy();
});
this.colorModes = getColorModes();
this.panelCtrl.editingTimeRegions = true;
}
render() {
this.panelCtrl.render();
}
addTimeRegion() {
this.panel.timeRegions.push({
op: 'time',
fromDayOfWeek: undefined,
from: undefined,
toDayOfWeek: undefined,
to: undefined,
colorMode: 'background6',
fill: true,
line: false,
});
this.panelCtrl.render();
}
removeTimeRegion(index) {
this.panel.timeRegions.splice(index, 1);
this.panelCtrl.render();
}
onFillColorChange(index) {
return newColor => {
this.panel.timeRegions[index].fillColor = newColor;
this.render();
};
}
onLineColorChange(index) {
return newColor => {
this.panel.timeRegions[index].lineColor = newColor;
this.render();
};
}
}
coreModule.directive('graphTimeRegionForm', () => {
return {
restrict: 'E',
templateUrl: 'public/app/plugins/panel/graph/time_regions_form.html',
controller: TimeRegionFormCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
panelCtrl: '=',
},
};
});

View File

@ -77,7 +77,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}; };
/** @ngInject */ /** @ngInject */
constructor($scope, $injector, private linkSrv) { constructor($scope, $injector, private linkSrv, private $sanitize) {
super($scope, $injector); super($scope, $injector);
_.defaults(this.panel, this.panelDefaults); _.defaults(this.panel, this.panelDefaults);
@ -398,14 +398,15 @@ class SingleStatCtrl extends MetricsPanelCtrl {
const $location = this.$location; const $location = this.$location;
const linkSrv = this.linkSrv; const linkSrv = this.linkSrv;
const $timeout = this.$timeout; const $timeout = this.$timeout;
const $sanitize = this.$sanitize;
const panel = ctrl.panel; const panel = ctrl.panel;
const templateSrv = this.templateSrv; const templateSrv = this.templateSrv;
let data, linkInfo; let data, linkInfo;
const $panelContainer = elem.find('.panel-container'); const $panelContainer = elem.find('.panel-container');
elem = elem.find('.singlestat-panel'); elem = elem.find('.singlestat-panel');
function applyColoringThresholds(value, valueString) { function applyColoringThresholds(valueString) {
const color = getColorForValue(data, value); const color = getColorForValue(data, data.value);
if (color) { if (color) {
return '<span style="color:' + color + '">' + valueString + '</span>'; return '<span style="color:' + color + '">' + valueString + '</span>';
} }
@ -413,8 +414,9 @@ class SingleStatCtrl extends MetricsPanelCtrl {
return valueString; return valueString;
} }
function getSpan(className, fontSize, value) { function getSpan(className, fontSize, applyColoring, value) {
value = templateSrv.replace(value, data.scopedVars); value = $sanitize(templateSrv.replace(value, data.scopedVars));
value = applyColoring ? applyColoringThresholds(value) : value;
return '<span class="' + className + '" style="font-size:' + fontSize + '">' + value + '</span>'; return '<span class="' + className + '" style="font-size:' + fontSize + '">' + value + '</span>';
} }
@ -422,25 +424,13 @@ class SingleStatCtrl extends MetricsPanelCtrl {
let body = '<div class="singlestat-panel-value-container">'; let body = '<div class="singlestat-panel-value-container">';
if (panel.prefix) { if (panel.prefix) {
let prefix = panel.prefix; body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, panel.colorPrefix, panel.prefix);
if (panel.colorPrefix) {
prefix = applyColoringThresholds(data.value, panel.prefix);
}
body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, prefix);
} }
let value = data.valueFormatted; body += getSpan('singlestat-panel-value', panel.valueFontSize, panel.colorValue, data.valueFormatted);
if (panel.colorValue) {
value = applyColoringThresholds(data.value, value);
}
body += getSpan('singlestat-panel-value', panel.valueFontSize, value);
if (panel.postfix) { if (panel.postfix) {
let postfix = panel.postfix; body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, panel.colorPostfix, panel.postfix);
if (panel.colorPostfix) {
postfix = applyColoringThresholds(data.value, panel.postfix);
}
body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, postfix);
} }
body += '</div>'; body += '</div>';

View File

@ -14,6 +14,8 @@ describe('SingleStatCtrl', () => {
get: () => {}, get: () => {},
}; };
const $sanitize = {};
SingleStatCtrl.prototype.panel = { SingleStatCtrl.prototype.panel = {
events: { events: {
on: () => {}, on: () => {},
@ -31,7 +33,7 @@ describe('SingleStatCtrl', () => {
describe(desc, () => { describe(desc, () => {
ctx.setup = setupFunc => { ctx.setup = setupFunc => {
beforeEach(() => { beforeEach(() => {
ctx.ctrl = new SingleStatCtrl($scope, $injector, {}); ctx.ctrl = new SingleStatCtrl($scope, $injector, {}, $sanitize);
setupFunc(); setupFunc();
ctx.ctrl.onDataReceived(ctx.data); ctx.ctrl.onDataReceived(ctx.data);
ctx.data = ctx.ctrl.data; ctx.data = ctx.ctrl.data;

View File

@ -21,8 +21,6 @@ export interface DataSource {
withCredentials: boolean; withCredentials: boolean;
meta?: PluginMeta; meta?: PluginMeta;
pluginExports?: PluginExports; pluginExports?: PluginExports;
init?: () => void;
testDatasource?: () => Promise<any>;
} }
export interface DataSourceSelectItem { export interface DataSourceSelectItem {

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