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]
init_cmds = [
["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
follow_symlinks = true
@ -14,5 +14,5 @@ watch_exts = [".go", ".ini", ".toml", ".template.html"]
build_delay = 1500
cmds = [
["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:
docker:
- image: grafana/build-container:1.2.0
- image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@ -175,7 +175,7 @@ jobs:
build:
docker:
- image: grafana/build-container:1.2.0
- image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@ -241,7 +241,7 @@ jobs:
build-enterprise:
docker:
- image: grafana/build-container:1.2.0
- image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@ -273,7 +273,7 @@ jobs:
build-all-enterprise:
docker:
- image: grafana/build-container:1.2.0
- image: grafana/build-container:1.2.1
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@ -359,6 +359,9 @@ jobs:
- run:
name: deploy to gcp
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:
docker:

View File

@ -2,6 +2,7 @@
### 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)
* **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)
@ -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)
* **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)
* **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
* **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**: 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)
* **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)
* **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)
* **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)
* **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**: 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)
* **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
* 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)
* **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:
@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
cd packaging/docker && docker build --tag grafana/grafana:dev .

View File

@ -128,6 +128,8 @@ func main() {
if goos == linux {
createLinuxPackages()
}
case "pkg-archive":
grunt(gruntBuildArg("package")...)
case "pkg-rpm":
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,250 +1,546 @@
{
"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": [
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
60
],
"type": "gt"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"enabled": true,
"frequency": "60s",
"handler": 1,
"name": "TestData - Always OK",
"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": 0
},
"id": 3,
"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": "1,20,90,30,5,0",
"target": ""
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 60
}
],
"timeFrom": null,
"timeShift": null,
"title": "Always OK",
"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": "125",
"min": "0",
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
177
],
"type": "gt"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"enabled": true,
"executionErrorState": "alerting",
"for": "0m",
"frequency": "60s",
"handler": 1,
"name": "TestData - Always Alerting",
"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": 0
},
"id": 4,
"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",
"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": [
1
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"15m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"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,
"title": "Alerting with TestData",
"schemaVersion": 16,
"style": "dark",
"tags": [
"grafana-test"
],
"style": "dark",
"timezone": "browser",
"editable": true,
"hideControls": false,
"sharedCrosshair": false,
"rows": [
{
"collapse": false,
"editable": true,
"height": 255.625,
"panels": [
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
60
],
"type": "gt"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"enabled": true,
"frequency": "60s",
"handler": 1,
"name": "TestData - Always OK",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"datasource": "gdev-testdata",
"editable": true,
"error": false,
"fill": 1,
"id": 3,
"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": [],
"span": 6,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenario": "random_walk",
"scenarioId": "csv_metric_values",
"stringInput": "1,20,90,30,5,0",
"target": ""
}
],
"thresholds": [
{
"value": 60,
"op": "gt",
"fill": true,
"line": true,
"colorMode": "critical"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Always OK",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": "",
"logBase": 1,
"max": "125",
"min": "0",
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
]
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
177
],
"type": "gt"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"enabled": true,
"frequency": "60s",
"handler": 1,
"name": "TestData - Always Alerting",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"datasource": "gdev-testdata",
"editable": true,
"error": false,
"fill": 1,
"id": 4,
"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": [],
"span": 6,
"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",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"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
}
]
}
],
"title": "New row"
}
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
@ -274,14 +570,8 @@
"30d"
]
},
"templating": {
"list": []
},
"annotations": {
"list": []
},
"schemaVersion": 13,
"version": 4,
"links": [],
"gnetId": null
}
"timezone": "browser",
"title": "Alerting with TestData",
"uid": "7MeksYbmk",
"version": 1
}

View File

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

View File

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

View File

@ -39,7 +39,7 @@ Currently alerting supports a limited form of high availability. Since v4.2.0 of
## 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**
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.
### 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
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
```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.
- `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.
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
[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

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
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
{{< docs-imagebox img="/img/docs/v51/graph-time-range.png" max-width= "900px" >}}

View File

@ -56,7 +56,7 @@ if [ -f "$DEFAULT" ]; then
. "$DEFAULT"
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() {
if [ `id -u` -ne 0 ]; then

View File

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

View File

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

View File

@ -60,7 +60,7 @@ fi
# overwrite settings from default file
[ -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() {
status -p $PID_FILE $NAME > /dev/null 2>&1

View File

@ -17,6 +17,7 @@ RuntimeDirectoryMode=0750
ExecStart=/usr/sbin/grafana-server \
--config=${CONF_FILE} \
--pidfile=${PID_FILE_DIR}/grafana-server.pid \
--packaging=rpm \
cfg:default.paths.logs=${LOG_DIR} \
cfg:default.paths.data=${DATA_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)
}
var response m.AlertStateType = m.AlertStatePending
var response m.AlertStateType = m.AlertStateUnknown
pausedState := "un-paused"
if cmd.Paused {
response = m.AlertStatePaused

View File

@ -39,6 +39,10 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
viewData.Settings["loginError"] = loginError
}
if tryOAuthAutoLogin(c) {
return
}
if !tryLoginUsingRememberCookie(c) {
c.HTML(200, ViewIndex, viewData)
return
@ -53,6 +57,24 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
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 {
// Check auto-login.
uname := c.GetCookie(setting.CookieUserName)

View File

@ -13,7 +13,7 @@ import (
"syscall"
"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/metrics"
_ "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 homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory")
var pidFile = flag.String("pidfile", "", "path to pid file")
var packaging = flag.String("packaging", "unknown", "describes the way Grafana was installed")
func main() {
v := flag.Bool("v", false, "prints current version and exits")
@ -79,6 +80,7 @@ func main() {
setting.BuildStamp = buildstampInt64
setting.BuildBranch = buildBranch
setting.IsEnterprise = extensions.IsEnterprise
setting.Packaging = validPackaging(*packaging)
metrics.SetBuildInformation(version, commit, buildBranch)
@ -95,6 +97,16 @@ func main() {
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) {
signalChan := 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
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`
// 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.
@ -397,11 +397,12 @@ func sendUsageStats(oauthProviders map[string]bool) {
metrics := map[string]interface{}{}
report := map[string]interface{}{
"version": version,
"metrics": metrics,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"edition": getEdition(),
"version": version,
"metrics": metrics,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"edition": getEdition(),
"packaging": setting.Packaging,
}
statsQuery := models.GetSystemStatsQuery{}
@ -447,6 +448,8 @@ func sendUsageStats(oauthProviders map[string]bool) {
}
metrics["stats.ds.other.count"] = dsOtherCount
metrics["stats.packaging."+setting.Packaging+".count"] = 1
dsAccessStats := models.GetDataSourceAccessStatsQuery{}
if err := bus.Dispatch(&dsAccessStats); err != nil {
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.LdapEnabled = true
setting.AuthProxyEnabled = true
setting.Packaging = "deb"
wg.Add(1)
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_generic_oauth.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"
AlertStateOK AlertStateType = "ok"
AlertStatePending AlertStateType = "pending"
AlertStateUnknown AlertStateType = "unknown"
)
const (
@ -39,7 +40,12 @@ var (
)
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 {
@ -66,12 +72,13 @@ type Alert struct {
PanelId int64
Name string
Message string
Severity string
Severity string //Unused
State AlertStateType
Handler int64
Handler int64 //Unused
Silenced bool
ExecutionError string
Frequency int64
For time.Duration
EvalData *simplejson.Json
NewStateDate time.Time

View File

@ -68,8 +68,13 @@ func (c *EvalContext) GetStateModel() *StateDescription {
Color: "#D63232",
Text: "Alerting",
}
case m.AlertStateUnknown:
return &StateDescription{
Color: "#888888",
Text: "Unknown",
}
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
}
// GetNewState returns the new state from the alert rule evaluation
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 {
c.log.Error("Alert Rule Result Error",
"ruleId", c.Rule.Id,
@ -125,11 +149,13 @@ func (c *EvalContext) GetNewState() m.AlertStateType {
return c.PrevAlertState
}
return c.Rule.ExecutionErrorState.ToAlertState()
}
} else if c.Firing {
if c.Firing {
return m.AlertStateAlerting
}
} else if c.NoDataFound {
if c.NoDataFound {
c.log.Info("Alert Rule returned no data",
"ruleId", c.Rule.Id,
"name", c.Rule.Name,

View File

@ -2,11 +2,11 @@ package alerting
import (
"context"
"fmt"
"errors"
"testing"
"time"
"github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
func TestStateIsUpdatedWhenNeeded(t *testing.T) {
@ -31,71 +31,176 @@ func TestStateIsUpdatedWhenNeeded(t *testing.T) {
})
}
func TestAlertingEvalContext(t *testing.T) {
Convey("Should compute and replace properly new rule state", t, func() {
func TestGetStateFromEvalContext(t *testing.T) {
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}}})
dummieError := fmt.Errorf("dummie error")
Convey("ok -> alerting", func() {
ctx.PrevAlertState = models.AlertStateOK
ctx.Firing = true
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)
})
})
tc.applyFn(ctx)
have := ctx.GetNewState()
if have != tc.expected {
t.Errorf("failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, string(have))
}
}
}

View File

@ -2,8 +2,8 @@ package alerting
import (
"errors"
"fmt"
"time"
"github.com/grafana/grafana/pkg/bus"
"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"}
}
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{
DashboardId: e.Dash.Id,
OrgId: e.OrgID,
@ -124,6 +133,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
Handler: jsonAlert.Get("handler").MustInt64(),
Message: jsonAlert.Get("message").MustString(),
Frequency: frequency,
For: forValue,
}
for _, condition := range jsonAlert.Get("conditions").MustArray() {

View File

@ -3,6 +3,7 @@ package alerting
import (
"io/ioutil"
"testing"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
@ -46,7 +47,7 @@ func TestAlertRuleExtraction(t *testing.T) {
return nil
})
json, err := ioutil.ReadFile("./test-data/graphite-alert.json")
json, err := ioutil.ReadFile("./testdata/graphite-alert.json")
So(err, ShouldBeNil)
Convey("Extractor should not modify the original json", func() {
@ -118,6 +119,11 @@ func TestAlertRuleExtraction(t *testing.T) {
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() {
So(alerts[0].Name, ShouldEqual, "name1")
So(alerts[0].Message, ShouldEqual, "desc1")
@ -140,7 +146,7 @@ func TestAlertRuleExtraction(t *testing.T) {
})
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)
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() {
panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json")
panelWithIdZero, err := ioutil.ReadFile("./testdata/panel-with-id-0.json")
So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(panelWithIdZero)
@ -172,7 +178,7 @@ func TestAlertRuleExtraction(t *testing.T) {
})
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)
dashJson, err := simplejson.NewJson(json)
@ -192,7 +198,7 @@ func TestAlertRuleExtraction(t *testing.T) {
})
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)
dashJson, err := simplejson.NewJson(json)
@ -221,7 +227,7 @@ func TestAlertRuleExtraction(t *testing.T) {
})
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)
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() {
json, err := ioutil.ReadFile("./test-data/dash-without-id.json")
json, err := ioutil.ReadFile("./testdata/dash-without-id.json")
So(err, ShouldBeNil)
dashJSON, err := simplejson.NewJson(json)

View File

@ -1,13 +1,60 @@
package notifiers
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
. "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) {
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.
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 {
return false
}

View File

@ -29,7 +29,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK,
prevState: m.AlertStatePending,
sendReminder: false,
state: &m.AlertNotificationState{},
expect: false,
},
@ -38,7 +37,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateAlerting,
prevState: m.AlertStateOK,
sendReminder: false,
state: &m.AlertNotificationState{},
expect: true,
},
@ -47,7 +45,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStatePending,
prevState: m.AlertStateOK,
sendReminder: false,
state: &m.AlertNotificationState{},
expect: false,
},
@ -56,7 +53,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
sendReminder: false,
state: &m.AlertNotificationState{},
expect: false,
},
@ -65,7 +61,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
sendReminder: true,
state: &m.AlertNotificationState{},
expect: false,
},
@ -74,7 +69,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
sendReminder: false,
state: &m.AlertNotificationState{},
expect: true,
},
@ -94,7 +88,6 @@ func TestShouldSendAlertNotification(t *testing.T) {
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
state: &m.AlertNotificationState{},
expect: true,
},
@ -132,6 +125,27 @@ func TestShouldSendAlertNotification(t *testing.T) {
prevState: m.AlertStateOK,
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,
},
}
@ -141,6 +155,10 @@ func TestShouldSendAlertNotification(t *testing.T) {
State: tc.prevState,
})
if tc.state == nil {
tc.state = &m.AlertNotificationState{}
}
evalContext.Rule.State = tc.newState
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
// with the last state change always sends a notification.
evalContext.Rule.StateChanges = cmd.Result.StateChanges
// Update the last state change of the alert rule in memory
evalContext.Rule.LastStateChange = time.Now()
}
// save annotation

View File

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

View File

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

View File

@ -193,7 +193,8 @@ func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
if alertToUpdate.ContainsUpdates(alert) {
alert.Updated = timeNow()
alert.State = alertToUpdate.State
sess.MustCols("message")
sess.MustCols("message", "for")
_, err := sess.ID(alert.Id).Update(alert)
if err != nil {
return err
@ -204,7 +205,7 @@ func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
} else {
alert.Updated = timeNow()
alert.Created = timeNow()
alert.State = m.AlertStatePending
alert.State = m.AlertStateUnknown
alert.NewStateDate = timeNow()
_, err := sess.Insert(alert)
@ -299,7 +300,7 @@ func PauseAlert(cmd *m.PauseAlertCommand) error {
params = append(params, string(m.AlertStatePaused))
params = append(params, timeNow())
} else {
params = append(params, string(m.AlertStatePending))
params = append(params, string(m.AlertStateUnknown))
params = append(params, timeNow())
}
@ -323,7 +324,7 @@ func PauseAllAlerts(cmd *m.PauseAllAlertCommand) error {
if cmd.Paused {
newState = string(m.AlertStatePaused)
} else {
newState = string(m.AlertStatePending)
newState = string(m.AlertStateUnknown)
}
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.PanelId, ShouldEqual, 1)
So(alert.Name, ShouldEqual, "Alerting title")
So(alert.State, ShouldEqual, "pending")
So(alert.State, ShouldEqual, m.AlertStateUnknown)
So(alert.NewStateDate, ShouldNotBeNil)
So(alert.EvalData, ShouldNotBeNil)
So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test")
@ -154,7 +154,7 @@ func TestAlertingDataAccess(t *testing.T) {
So(query.Result[0].Name, ShouldEqual, "Name")
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("add index alert_notification_state org_id & alert_id & notifier_id",
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
ApplicationName string
// packaging
Packaging = "unknown"
// Paths
HomePath string
PluginsPath string
@ -112,6 +115,7 @@ var (
ExternalUserMngLinkUrl string
ExternalUserMngLinkName string
ExternalUserMngInfo string
OAuthAutoLogin bool
ViewersCanEdit bool
// Http auth
@ -626,6 +630,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
auth := iniFile.Section("auth")
DisableLoginForm = auth.Key("disable_login_form").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()
// 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) {
var data struct {
Id string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
@ -47,6 +48,7 @@ func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
}
return &BasicUserInfo{
Id: data.Id,
Name: data.Name,
Email: data.Email,
Login: data.Email,

View File

@ -46,6 +46,7 @@ func init() {
"AWS/Billing": {"EstimatedCharges"},
"AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"},
"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/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"},
@ -121,6 +122,7 @@ func init() {
"AWS/Billing": {"ServiceName", "LinkedAccount", "Currency"},
"AWS/CloudFront": {"DistributionId", "Region"},
"AWS/CloudSearch": {},
"AWS/CloudHSM": {"Region", "ClusterId", "HsmId"},
"AWS/Connect": {"InstanceId", "MetricGroup", "Participant", "QueueName", "Stream Type", "Type of Connection"},
"AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"},
"AWS/DX": {"ConnectionId"},

View File

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

View File

@ -64,10 +64,10 @@
<div class="search-results" ng-show="ctrl.sections.length > 0">
<div class="search-results-filter-row">
<gf-form-switch
<gf-form-checkbox
on-change="ctrl.onSelectAllChanged()"
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="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 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)">
<gf-form-switch
<gf-form-checkbox
ng-show="ctrl.editable"
on-change="ctrl.selectionChanged($event)"
checked="section.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__section">
</gf-form-switch>
</gf-form-checkbox>
</div>
<i class="search-section__header__icon" ng-class="section.icon"></i>
<span class="search-section__header__text">{{::section.title}}</span>
@ -22,12 +22,12 @@
<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}}" >
<div ng-click="ctrl.toggleSelection(item, $event)">
<gf-form-switch
<gf-form-checkbox
ng-show="ctrl.editable"
on-change="ctrl.selectionChanged()"
checked="item.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__item">
</gf-form-switch>
</gf-form-checkbox>
</div>
<span class="search-item__icon">
<i class="gicon mini gicon-dashboard-list"></i>

View File

@ -1,16 +1,33 @@
import coreModule from 'app/core/core_module';
const template = `
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer" ng-show="ctrl.label">
{{ctrl.label}}
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
{{ctrl.tooltip}}
</info-popover>
<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-switch {{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__slider"></span>
</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>
<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()">
<label for="check-{{ctrl.id}}" data-on="Yes" data-off="No"></label>
</div>
`;
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('gfFormCheckbox', checkboxDirective);

View File

@ -31,6 +31,7 @@ export interface LogSearchMatch {
}
export interface LogRow {
duplicates?: number;
entry: string;
key: string; // timestamp + labels
labels: string;
@ -71,6 +72,53 @@ export interface LogsStreamLabels {
[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[] {
// Graph time series by log level
const seriesByLevel = {};

View File

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

View File

@ -32,8 +32,8 @@ export class KeybindingSrv {
this.setupGlobal();
appEvents.on('show-modal', () => (this.modalOpen = true));
$rootScope.onAppEvent('timepickerOpen', () => (this.timepickerOpen = true));
$rootScope.onAppEvent('timepickerClosed', () => (this.timepickerOpen = false));
appEvents.on('timepickerOpen', () => (this.timepickerOpen = true));
appEvents.on('timepickerClosed', () => (this.timepickerOpen = false));
}
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 ALERTING_COLOR = 'rgba(237, 46, 24, 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;
const colors = [

View File

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

View File

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

View File

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

View File

@ -1,147 +1,159 @@
<div class="edit-tab-with-sidemenu" ng-if="ctrl.alert">
<aside class="edit-sidemenu-aside">
<ul class="edit-sidemenu">
<li ng-class="{active: ctrl.subTabIndex === 0}">
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 1}">
<a ng-click="ctrl.changeTabIndex(1)">
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 2}">
<a ng-click="ctrl.changeTabIndex(2)">State history</a>
</li>
<aside class="edit-sidemenu-aside">
<ul class="edit-sidemenu">
<li ng-class="{active: ctrl.subTabIndex === 0}">
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 1}">
<a ng-click="ctrl.changeTabIndex(1)">
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 2}">
<a ng-click="ctrl.changeTabIndex(2)">State history</a>
</li>
<li>
<a ng-click="ctrl.delete()">Delete</a>
</li>
</ul>
</aside>
<a ng-click="ctrl.delete()">Delete</a>
</li>
</ul>
</aside>
<div class="edit-tab-content">
<div ng-if="ctrl.subTabIndex === 0">
<div class="alert alert-error m-b-2" ng-show="ctrl.error">
<i class="fa fa-warning"></i> {{ctrl.error}}
</div>
<div class="edit-tab-content">
<div ng-if="ctrl.subTabIndex === 0">
<div class="alert alert-error m-b-2" ng-show="ctrl.error">
<i class="fa fa-warning"></i> {{ctrl.error}}
</div>
<div class="gf-form-group">
<h5 class="section-heading">Alert Config</h5>
<div class="gf-form">
<span class="gf-form-label width-6">Name</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
<span class="gf-form-label">Evaluate every</span>
<input class="gf-form-input max-width-5" type="text" ng-model="ctrl.alert.frequency"></input>
</div>
</div>
<div class="gf-form-group">
<h5 class="section-heading">Alert Config</h5>
<div class="gf-form">
<span class="gf-form-label width-6">Name</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
</div>
<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 class="gf-form-group">
<h5 class="section-heading">Conditions</h5>
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
<div class="gf-form">
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
</query-part-editor>
<span class="gf-form-label query-keyword">OF</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
</query-part-editor>
</div>
<div class="gf-form">
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()"></input>
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()"></input>
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
<i class="fa fa-trash"></i>
</a>
</label>
</div>
</div>
<div class="gf-form-group">
<h5 class="section-heading">Conditions</h5>
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
<div class="gf-form">
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
</query-part-editor>
<span class="gf-form-label query-keyword">OF</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
</query-part-editor>
</div>
<div class="gf-form">
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()"></input>
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()"></input>
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
<i class="fa fa-trash"></i>
</a>
</label>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label dropdown">
<a class="pointer dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-plus"></i>
</a>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
</li>
</ul>
</label>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label dropdown">
<a class="pointer dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-plus"></i>
</a>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
</li>
</ul>
</label>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-18">If no data or all values are null</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
</select>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-18">If no data or all values are null</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
</select>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label width-18">If execution error or timeout</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
</select>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label width-18">If execution error or timeout</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
</select>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.test()">
Test Rule
</button>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.test()">
Test Rule
</button>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.testing">
Evaluating rule <i class="fa fa-spinner fa-spin"></i>
</div>
<div class="gf-form-group" ng-if="ctrl.testing">
Evaluating rule <i class="fa fa-spinner fa-spin"></i>
</div>
<div class="gf-form-group" ng-if="ctrl.testResult">
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.testResult">
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
<h5 class="section-heading">Notifications</h5>
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Send to</span>
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
<i class="{{nc.iconClass}}"></i>&nbsp;{{nc.name}}&nbsp;
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
</span>
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
</div>
</div>
<div class="gf-form gf-form--v-stretch">
<span class="gf-form-label width-8">Message</span>
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
<h5 class="section-heading">Notifications</h5>
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Send to</span>
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
<i class="{{nc.iconClass}}"></i>&nbsp;{{nc.name}}&nbsp;
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
</span>
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
</div>
</div>
<div class="gf-form gf-form--v-stretch">
<span class="gf-form-label width-8">Message</span>
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
</div>
</div>
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i>&nbsp;Clear history</button>
<h5 class="section-heading" style="whitespace: nowrap">
State history <span class="muted small">(last 50 state changes)</span>
</h5>
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i>&nbsp;Clear history</button>
<h5 class="section-heading" style="whitespace: nowrap">
State history <span class="muted small">(last 50 state changes)</span>
</h5>
<div ng-show="ctrl.alertHistory.length === 0">
<br>
<i>No state changes recorded</i>
</div>
<div ng-show="ctrl.alertHistory.length === 0">
<br>
<i>No state changes recorded</i>
</div>
<ol class="alert-rule-list" >
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">

View File

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

View File

@ -32,7 +32,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv,
if (event.alertId) {
const stateModel = alertDef.getStateDisplayModel(event.newState);
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);
if (event.text) {
text = text + '<br />' + event.text;

View File

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

View File

@ -77,6 +77,10 @@ export class DashboardSrv {
postSave(clone, data) {
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 currentPath = this.$location.path();
@ -84,9 +88,6 @@ export class DashboardSrv {
this.$location.url(newUrl).replace();
}
this.$rootScope.appEvent('dashboard-saved', this.dash);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
return this.dash;
}

View File

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

View File

@ -30,8 +30,8 @@
<tbody>
<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">
<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-switch>
<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-checkbox>
</td>
<td class="text-center">{{revision.version}}</td>
<td>{{revision.createdDateString}}</td>

View File

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

View File

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

View File

@ -3,8 +3,9 @@ import { hot } from 'react-hot-loader';
import Select from 'react-select';
import _ from 'lodash';
import { DataSource } from 'app/types/datasources';
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 colors from 'app/core/utils/colors';
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 NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
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 Graph from './Graph';
import Logs from './Logs';
@ -24,7 +27,6 @@ import Table from './Table';
import ErrorBoundary from './ErrorBoundary';
import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { DataSource } from 'app/types/datasources';
const MAX_HISTORY_ITEMS = 100;
@ -77,7 +79,7 @@ function updateHistory(history: HistoryItem[], datasourceId: string, queries: st
}
interface ExploreProps {
datasourceSrv: any;
datasourceSrv: DatasourceSrv;
onChangeSplit: (split: boolean, state?: ExploreState) => void;
onSaveState: (key: string, state: ExploreState) => void;
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.
* 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[];
/**
@ -125,6 +128,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
range: initialRange,
showingGraph: true,
showingLogs: true,
showingStartPage: false,
showingTable: true,
supportsGraph: 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 supportsLogs = datasource.meta.logs;
const supportsTable = datasource.meta.metrics;
@ -193,12 +197,33 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
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) => ({
...q,
key: generateQueryKey(i),
query: this.queryExpressions[i],
query: queryExpressions[i],
}));
this.queryExpressions = queryExpressions;
// Custom components
const StartPage = datasource.pluginExports.ExploreStartPage;
@ -215,6 +240,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasourceLoading: false,
datasourceName: datasource.name,
queries: nextQueries,
showingStartPage: Boolean(StartPage),
},
() => {
if (datasourceError === null) {
@ -258,6 +284,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
};
onChangeDatasource = async option => {
const origin = this.state.datasource;
this.setState({
datasource: null,
datasourceError: null,
@ -266,7 +293,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
});
const datasourceName = option.value;
const datasource = await this.props.datasourceSrv.get(datasourceName);
this.setDatasource(datasource);
this.setDatasource(datasource as any, origin);
};
onChangeQuery = (value: string, index: number, override?: boolean) => {
@ -305,10 +332,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onClickClear = () => {
this.queryExpressions = [''];
this.setState(
{
prevState => ({
queries: ensureQueries(),
queryTransactions: [],
},
showingStartPage: Boolean(prevState.StartPage),
}),
this.saveState
);
};
@ -539,6 +567,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return {
queryTransactions: nextQueryTransactions,
showingStartPage: false,
};
});
@ -765,16 +794,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
range,
showingGraph,
showingLogs,
showingStartPage,
showingTable,
supportsGraph,
supportsLogs,
supportsTable,
} = this.state;
const showingBoth = showingGraph && showingTable;
const graphHeight = showingBoth ? '200px' : '400px';
const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
const logsButtonActive = showingLogs ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const graphHeight = showingGraph && showingTable ? '200px' : '400px';
const exploreClass = split ? 'explore explore-split' : 'explore';
const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined;
const graphRangeIntervals = getIntervals(graphRange, datasource, this.el ? this.el.offsetWidth : 0);
@ -799,8 +825,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
)
: undefined;
const loading = queryTransactions.some(qt => !qt.done);
const showStartPages = StartPage && queryTransactions.length === 0;
const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length;
return (
<div className={exploreClass} ref={this.getRef}>
@ -889,55 +913,47 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/>
<main className="m-t-2">
<ErrorBoundary>
{showStartPages && <StartPage onClickQuery={this.onClickQuery} />}
{!showStartPages && (
{showingStartPage && <StartPage onClickQuery={this.onClickQuery} />}
{!showingStartPage && (
<>
{viewModeCount > 1 && (
<div className="result-options">
{supportsGraph ? (
<button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
Graph
</button>
) : 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 && (
{supportsGraph && (
<Panel
label="Graph"
isOpen={showingGraph}
loading={graphLoading}
onToggle={this.onClickGraphButton}
>
<Graph
data={graphResult}
height={graphHeight}
loading={graphLoading}
id={`explore-graph-${position}`}
onChangeTime={this.onChangeTime}
range={graphRange}
split={split}
/>
)}
{supportsTable && showingTable ? (
<div className="panel-container m-t-2">
</Panel>
)}
{supportsTable && (
<Panel
label="Table"
loading={tableLoading}
isOpen={showingTable}
onToggle={this.onClickTableButton}
>
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
</div>
) : null}
{supportsLogs && showingLogs ? (
<Logs
data={logsResult}
loading={logsLoading}
position={position}
onChangeTime={this.onChangeTime}
range={range}
/>
) : null}
</Panel>
)}
{supportsLogs && (
<Panel label="Logs" loading={logsLoading} isOpen={showingLogs} onToggle={this.onClickLogsButton}>
<Logs
data={logsResult}
loading={logsLoading}
position={position}
onChangeTime={this.onChangeTime}
range={range}
/>
</Panel>
)}
</>
)}
</ErrorBoundary>

View File

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

View File

@ -2,7 +2,7 @@ import React, { Fragment, PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
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 { Switch } from 'app/core/components/Switch/Switch';
@ -32,6 +32,7 @@ interface LogsProps {
}
interface LogsState {
dedup: LogsDedupStrategy;
showLabels: boolean;
showLocalTime: boolean;
showUtc: boolean;
@ -39,11 +40,21 @@ interface LogsState {
export default class Logs extends PureComponent<LogsProps, LogsState> {
state = {
dedup: LogsDedupStrategy.none,
showLabels: true,
showLocalTime: true,
showUtc: false,
};
onChangeDedup = (dedup: LogsDedupStrategy) => {
this.setState(prevState => {
if (prevState.dedup === dedup) {
return { dedup: LogsDedupStrategy.none };
}
return { dedup };
});
};
onChangeLabels = (event: React.SyntheticEvent) => {
const target = event.target as HTMLInputElement;
this.setState({
@ -67,9 +78,18 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
render() {
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 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) {
cssColumnSizes.push('minmax(100px, max-content)');
}
@ -97,15 +117,39 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
/>
</div>
<div className="panel-container logs-options">
<div className="logs-options">
<div className="logs-controls">
<Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} small />
<Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} 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 &&
data.meta && (
meta && (
<div className="logs-meta">
{data.meta.map(item => (
{meta.map(item => (
<div className="logs-meta-item" key={item.label}>
<span className="logs-meta-item__label">{item.label}:</span>
<span className="logs-meta-item__value">{item.value}</span>
@ -116,33 +160,38 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
</div>
</div>
<div className="panel-container">
{loading && <div className="explore-panel__loader" />}
<div className="logs-entries" style={logEntriesStyle}>
{hasData &&
data.rows.map(row => (
<Fragment key={row.key}>
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
{showUtc && <div title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>{row.timestamp}</div>}
{showLocalTime && <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>}
{showLabels && (
<div className="max-width" title={row.labels}>
{row.labels}
<div className="logs-entries" style={logEntriesStyle}>
{hasData &&
dedupedData.rows.map(row => (
<Fragment key={row.key}>
<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>
<Highlighter
textToHighlight={row.entry}
searchWords={row.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName="logs-row-match-highlight"
/>
</div>
{showUtc && <div title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>{row.timestamp}</div>}
{showLocalTime && <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>}
{showLabels && (
<div className="max-width" title={row.labels}>
{row.labels}
</div>
</Fragment>
))}
</div>
{!loading && !hasData && 'No data was returned.'}
)}
<div>
<Highlighter
textToHighlight={row.entry}
searchWords={row.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName="logs-row-match-highlight"
/>
</div>
</Fragment>
))}
</div>
{!loading && !hasData && 'No data was returned.'}
</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
exports[`Render should render component 1`] = `
<div
className="panel-container"
>
<Fragment>
<div
className="explore-graph"
id="graph"
@ -456,13 +454,11 @@ exports[`Render should render component 1`] = `
]
}
/>
</div>
</Fragment>
`;
exports[`Render should render component with disclaimer 1`] = `
<div
className="panel-container"
>
<Fragment>
<div
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`] = `
<div
className="panel-container"
>
<Fragment>
<div
className="explore-graph"
id="graph"
@ -971,5 +965,5 @@ exports[`Render should show query return no time series 1`] = `
<Legend
data={Array []}
/>
</div>
</Fragment>
`;

View File

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

View File

@ -48,6 +48,16 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
let lastAlertState;
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() {
if (panelScrollbar) {
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
ctrl.calculatePanelHeight();
@ -119,7 +142,11 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
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);
}
@ -170,6 +197,9 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
scope.$apply(ctrl.openInspector.bind(ctrl));
});
elem.on('mouseenter', mouseEnter);
elem.on('mouseleave', mouseLeave);
scope.$on('$destroy', () => {
elem.off();
cornerInfoElem.off();

View File

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

View File

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

View File

@ -71,15 +71,21 @@
<h3 class="page-heading">Auth</h3>
<div class="gf-form-group">
<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-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="Basic Auth" checked="current.basicAuth" label-class="width-10" switch-class="max-width-6"></gf-form-checkbox>
<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 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-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>
<gf-form-checkbox 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-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 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>

View File

@ -124,7 +124,11 @@ export class TeamGroupSync extends PureComponent<Props, State> {
</button>
<div className="empty-list-cta__pro-tip">
<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
</a>
</div>

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import _ from 'lodash';
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 { mergeStreamsToLogs } from './result_transformer';
import { LogsStream, LogsModel, makeSeriesForLogs } from 'app/core/logs_model';
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) {
// HACK to get label values for {job=|}, will be replaced when implementing LoggingQueryField
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,
TypeaheadOutput,
} from 'app/types/explore';
import { parseSelector } from 'app/plugins/datasource/prometheus/language_utils';
import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasource/prometheus/language_utils';
import PromqlSyntax from 'app/plugins/datasource/prometheus/promql';
import { DataQuery } from 'app/types';
const DEFAULT_KEYS = ['job', 'namespace'];
const EMPTY_SELECTOR = '{}';
@ -158,6 +158,56 @@ export default class LoggingLanguageProvider extends LanguageProvider {
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() {
const url = '/api/prom/label';
try {

View File

@ -10,6 +10,7 @@ import { BackendSrv } from 'app/core/services/backend_srv';
import addLabelToQuery from './add_label_to_query';
import { getQueryHints } from './query_hints';
import { expandRecordingRules } from './language_utils';
export function alignRange(start, end, step) {
const alignedEnd = Math.ceil(end / step) * step;
@ -468,11 +469,8 @@ export class PrometheusDatasource {
return `sum(${query.trim()}) by ($1)`;
}
case 'EXPAND_RULES': {
const mapping = action.mapping;
if (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]);
if (action.mapping) {
return expandRecordingRules(query, action.mapping);
}
}
default:

View File

@ -78,9 +78,24 @@ export default class PromQlLanguageProvider extends LanguageProvider {
};
// 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
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
if (_.includes(wrapperClasses, 'context-range')) {
// 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 {|}
return this.getLabelCompletionItems.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
// Suggestions for sum(metric) by (|)
return this.getAggregationCompletionItems.apply(this, arguments);
} else if (
// Show default suggestions in a couple of scenarios
(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
) {
} else if (empty) {
// Suggestions for empty query field
return this.getEmptyCompletionItems(context || {});
} else if (prefixUnrecognized || safeEmptyPrefix || isNextOperand) {
// Show term suggestions in a couple of scenarios
return this.getTermCompletionItems();
}
return {
@ -106,8 +121,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
getEmptyCompletionItems(context: any): TypeaheadOutput {
const { history } = context;
const { metrics } = this;
const suggestions: CompletionItemGroup[] = [];
let suggestions: CompletionItemGroup[] = [];
if (history && history.length > 0) {
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({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionKind),
});
if (metrics) {
if (metrics && metrics.length > 0) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),

View File

@ -24,8 +24,8 @@ export function processLabels(labels, withName = false) {
}
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
const selectorRegexp = /\{[^}]*?\}/;
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
export const selectorRegexp = /\{[^}]*?\}/;
export const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
if (!query.match(selectorRegexp)) {
// Special matcher for metrics
@ -83,3 +83,9 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any
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: [] } }),
};
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(2);
describe('empty query suggestions', () => {
it('returns default suggestions on emtpty context', () => {
const instance = new LanguageProvider(datasource);
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',
},
]);
});
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', () => {
it('returns range suggestions in range context', () => {
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.refresher).toBeUndefined();
expect(result.suggestions).toEqual([
@ -31,20 +60,54 @@ describe('Language completion provider', () => {
});
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 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.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 result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] });
const value = Plain.deserialize('*');
const result = instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] });
expect(result.context).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()', () => {
let parsed;
@ -62,3 +62,25 @@ describe('parseSelector()', () => {
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="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="Pending" label-class="width-10" checked="ctrl.stateFilter['pending']" on-change="ctrl.updateStateFilter()"></gf-form-switch>
</div>
</div>

View File

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

View File

@ -1,6 +1,7 @@
import './graph';
import './series_overrides_ctrl';
import './thresholds_form';
import './time_regions_form';
import template from './template';
import _ from 'lodash';
@ -111,6 +112,7 @@ class GraphCtrl extends MetricsPanelCtrl {
// other style overrides
seriesOverrides: [],
thresholds: [],
timeRegions: [],
};
/** @ngInject */
@ -133,9 +135,9 @@ class GraphCtrl extends MetricsPanelCtrl {
}
onInitEditMode() {
this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4);
this.addEditorTab('Axes', axesEditorComponent, 2);
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) {
// 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>
</a>
</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>
</aside>
@ -132,4 +137,8 @@
<graph-threshold-form panel-ctrl="ctrl"></graph-threshold-form>
</div>
<div class="edit-tab-content" ng-if="ctrl.subTabIndex === 3">
<graph-time-region-form panel-ctrl="ctrl"></graph-time-region-form>
</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', () => {
return {
restrict: 'E',
template: template,
templateUrl: 'public/app/plugins/panel/graph/thresholds_form.html',
controller: ThresholdFormCtrl,
bindToController: true,
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 */
constructor($scope, $injector, private linkSrv) {
constructor($scope, $injector, private linkSrv, private $sanitize) {
super($scope, $injector);
_.defaults(this.panel, this.panelDefaults);
@ -398,14 +398,15 @@ class SingleStatCtrl extends MetricsPanelCtrl {
const $location = this.$location;
const linkSrv = this.linkSrv;
const $timeout = this.$timeout;
const $sanitize = this.$sanitize;
const panel = ctrl.panel;
const templateSrv = this.templateSrv;
let data, linkInfo;
const $panelContainer = elem.find('.panel-container');
elem = elem.find('.singlestat-panel');
function applyColoringThresholds(value, valueString) {
const color = getColorForValue(data, value);
function applyColoringThresholds(valueString) {
const color = getColorForValue(data, data.value);
if (color) {
return '<span style="color:' + color + '">' + valueString + '</span>';
}
@ -413,8 +414,9 @@ class SingleStatCtrl extends MetricsPanelCtrl {
return valueString;
}
function getSpan(className, fontSize, value) {
value = templateSrv.replace(value, data.scopedVars);
function getSpan(className, fontSize, applyColoring, value) {
value = $sanitize(templateSrv.replace(value, data.scopedVars));
value = applyColoring ? applyColoringThresholds(value) : value;
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">';
if (panel.prefix) {
let prefix = panel.prefix;
if (panel.colorPrefix) {
prefix = applyColoringThresholds(data.value, panel.prefix);
}
body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, prefix);
body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, panel.colorPrefix, panel.prefix);
}
let value = data.valueFormatted;
if (panel.colorValue) {
value = applyColoringThresholds(data.value, value);
}
body += getSpan('singlestat-panel-value', panel.valueFontSize, value);
body += getSpan('singlestat-panel-value', panel.valueFontSize, panel.colorValue, data.valueFormatted);
if (panel.postfix) {
let postfix = panel.postfix;
if (panel.colorPostfix) {
postfix = applyColoringThresholds(data.value, panel.postfix);
}
body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, postfix);
body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, panel.colorPostfix, panel.postfix);
}
body += '</div>';

View File

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

View File

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

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