diff --git a/.bra.toml b/.bra.toml index aa7a1680adc..5be42ceebbf 100644 --- a/.bra.toml +++ b/.bra.toml @@ -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"] ] diff --git a/.circleci/config.yml b/.circleci/config.yml index 20339ac9f5a..f8f0ba6789a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index e5fceb28265..43743a3ba2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Makefile b/Makefile index fcb740d2fac..6410714d4fc 100644 --- a/Makefile +++ b/Makefile @@ -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 . diff --git a/build.go b/build.go index dc789670f62..9d5216de1d0 100644 --- a/build.go +++ b/build.go @@ -128,6 +128,8 @@ func main() { if goos == linux { createLinuxPackages() } + case "pkg-archive": + grunt(gruntBuildArg("package")...) case "pkg-rpm": grunt(gruntBuildArg("release")...) diff --git a/devenv/dev-dashboards/panel_tests_graph_time_regions.json b/devenv/dev-dashboards/panel_tests_graph_time_regions.json new file mode 100644 index 00000000000..8d0bae1221c --- /dev/null +++ b/devenv/dev-dashboards/panel_tests_graph_time_regions.json @@ -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 +} \ No newline at end of file diff --git a/devenv/dev-dashboards/testdata_alerts.json b/devenv/dev-dashboards/testdata_alerts.json index 8c2edebf155..8fd7d4d9db5 100644 --- a/devenv/dev-dashboards/testdata_alerts.json +++ b/devenv/dev-dashboards/testdata_alerts.json @@ -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 +} \ No newline at end of file diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml index ce8630d88a4..1195e2a977c 100644 --- a/devenv/docker/ha_test/docker-compose.yaml +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -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 diff --git a/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet b/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet index 86ded7e79d6..e9b8abfbb9c 100644 --- a/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet +++ b/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet @@ -39,6 +39,7 @@ local alertDashboardTemplate = { "executionErrorState": "alerting", "frequency": "10s", "handler": 1, + "for": "1m", "name": "bulk alerting", "noDataState": "no_data", "notifications": [ diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md index 488619055e2..387132ecfeb 100644 --- a/docs/sources/alerting/rules.md +++ b/docs/sources/alerting/rules.md @@ -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. diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index a372600ac46..d4360d554c1 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -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 diff --git a/docs/sources/features/panels/graph.md b/docs/sources/features/panels/graph.md index 5a010ceca40..44fa0e7c0db 100644 --- a/docs/sources/features/panels/graph.md +++ b/docs/sources/features/panels/graph.md @@ -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" >}} diff --git a/packaging/deb/init.d/grafana-server b/packaging/deb/init.d/grafana-server index 567da94f881..5c1d9c8271a 100755 --- a/packaging/deb/init.d/grafana-server +++ b/packaging/deb/init.d/grafana-server @@ -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 diff --git a/packaging/deb/systemd/grafana-server.service b/packaging/deb/systemd/grafana-server.service index acd2a360a93..b1e2e387e4d 100644 --- a/packaging/deb/systemd/grafana-server.service +++ b/packaging/deb/systemd/grafana-server.service @@ -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} \ diff --git a/packaging/docker/run.sh b/packaging/docker/run.sh index bc001bdf90a..63f20742c96 100755 --- a/packaging/docker/run.sh +++ b/packaging/docker/run.sh @@ -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" \ diff --git a/packaging/rpm/init.d/grafana-server b/packaging/rpm/init.d/grafana-server index cefe212116c..b7b41e58e8d 100755 --- a/packaging/rpm/init.d/grafana-server +++ b/packaging/rpm/init.d/grafana-server @@ -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 diff --git a/packaging/rpm/systemd/grafana-server.service b/packaging/rpm/systemd/grafana-server.service index f228c8d8b14..ad5006d1d4c 100644 --- a/packaging/rpm/systemd/grafana-server.service +++ b/packaging/rpm/systemd/grafana-server.service @@ -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} \ diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index c68cee50948..66b3b504946 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -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 diff --git a/pkg/api/login.go b/pkg/api/login.go index 1083f89adfd..05afc40e59a 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -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) diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index c7c1ff3aff7..285bd7ff1c3 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -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) diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 5709e3e3213..326514a9687 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -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) diff --git a/pkg/metrics/metrics_test.go b/pkg/metrics/metrics_test.go index 43739221f1e..c27d6f64b8c 100644 --- a/pkg/metrics/metrics_test.go +++ b/pkg/metrics/metrics_test.go @@ -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) }) }) diff --git a/pkg/models/alert.go b/pkg/models/alert.go index aaf9c50197a..e8171f8cf0b 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -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 diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index d0441d379b7..4db942e0a55 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -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, diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go index 750fa959683..af7e66b2f07 100644 --- a/pkg/services/alerting/eval_context_test.go +++ b/pkg/services/alerting/eval_context_test.go @@ -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)) + } + } } diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index 0abacc91313..e33e3dc2af3 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -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() { diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 0890b9e1bd1..9665a657bb7 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -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) diff --git a/pkg/services/alerting/notifiers/alertmanager_test.go b/pkg/services/alerting/notifiers/alertmanager_test.go index 3549b536e48..7510742ed17 100644 --- a/pkg/services/alerting/notifiers/alertmanager_test.go +++ b/pkg/services/alerting/notifiers/alertmanager_test.go @@ -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() { diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index d141d6cd257..d4a9975bcba 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -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 } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 5062828cb4f..3fd4447eefe 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -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} diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 420ffeb9a55..ce12a8a6b96 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -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 diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index 999611f15c4..d2a505145ac 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -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 diff --git a/pkg/services/alerting/test-data/collapsed-panels.json b/pkg/services/alerting/testdata/collapsed-panels.json similarity index 100% rename from pkg/services/alerting/test-data/collapsed-panels.json rename to pkg/services/alerting/testdata/collapsed-panels.json diff --git a/pkg/services/alerting/test-data/dash-without-id.json b/pkg/services/alerting/testdata/dash-without-id.json similarity index 100% rename from pkg/services/alerting/test-data/dash-without-id.json rename to pkg/services/alerting/testdata/dash-without-id.json diff --git a/pkg/services/alerting/test-data/graphite-alert.json b/pkg/services/alerting/testdata/graphite-alert.json similarity index 98% rename from pkg/services/alerting/test-data/graphite-alert.json rename to pkg/services/alerting/testdata/graphite-alert.json index 5f23e224f9a..3cb4ae1dd22 100644 --- a/pkg/services/alerting/test-data/graphite-alert.json +++ b/pkg/services/alerting/testdata/graphite-alert.json @@ -23,6 +23,7 @@ "message": "desc1", "handler": 1, "frequency": "60s", + "for": "2m", "conditions": [ { "type": "query", diff --git a/pkg/services/alerting/test-data/influxdb-alert.json b/pkg/services/alerting/testdata/influxdb-alert.json similarity index 100% rename from pkg/services/alerting/test-data/influxdb-alert.json rename to pkg/services/alerting/testdata/influxdb-alert.json diff --git a/pkg/services/alerting/test-data/panel-with-id-0.json b/pkg/services/alerting/testdata/panel-with-id-0.json similarity index 100% rename from pkg/services/alerting/test-data/panel-with-id-0.json rename to pkg/services/alerting/testdata/panel-with-id-0.json diff --git a/pkg/services/alerting/test-data/panels-missing-id.json b/pkg/services/alerting/testdata/panels-missing-id.json similarity index 100% rename from pkg/services/alerting/test-data/panels-missing-id.json rename to pkg/services/alerting/testdata/panels-missing-id.json diff --git a/pkg/services/alerting/test-data/v5-dashboard.json b/pkg/services/alerting/testdata/v5-dashboard.json similarity index 100% rename from pkg/services/alerting/test-data/v5-dashboard.json rename to pkg/services/alerting/testdata/v5-dashboard.json diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 2f17402b80c..62ab348664f 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -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()) diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index d97deb45f0e..40867e96b4d 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/pkg/services/sqlstore/alert_test.go @@ -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) }) }) diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index 198a47b50ff..b5aeb26483c 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -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, + })) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index afae642f5b3..76a50eefb09 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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 diff --git a/pkg/social/google_oauth.go b/pkg/social/google_oauth.go index e9ab08305f6..05ae2a481f2 100644 --- a/pkg/social/google_oauth.go +++ b/pkg/social/google_oauth.go @@ -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, diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 1a860519f2b..76eadf1c8dc 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -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"}, diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index ab0a3f26a88..21f518a682b 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -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 = {}; diff --git a/public/app/core/services/analytics.ts b/public/app/core/services/analytics.ts index be4371adb26..40e20b16a29 100644 --- a/public/app/core/services/analytics.ts +++ b/public/app/core/services/analytics.ts @@ -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'); diff --git a/public/app/core/services/dynamic_directive_srv.ts b/public/app/core/services/dynamic_directive_srv.ts index 9b7ede59853..c27842ab54f 100644 --- a/public/app/core/services/dynamic_directive_srv.ts +++ b/public/app/core/services/dynamic_directive_srv.ts @@ -21,7 +21,6 @@ class DynamicDirectiveSrv { } if (!directiveInfo.fn.registered) { - console.log('register panel tab'); coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn); directiveInfo.fn.registered = true; } diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 6fe57dfa77a..c02f6850e8b 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -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() { diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts new file mode 100644 index 00000000000..5e427468339 --- /dev/null +++ b/public/app/core/specs/logs_model.test.ts @@ -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]', + }, + ]); + }); +}); diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts index 16214679996..13e02b76e30 100644 --- a/public/app/core/utils/colors.ts +++ b/public/app/core/utils/colors.ts @@ -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 = [ diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx index d25fc659af5..f94134f3ee1 100644 --- a/public/app/features/alerting/AlertRuleList.tsx +++ b/public/app/features/alerting/AlertRuleList.tsx @@ -29,6 +29,7 @@ export class AlertRuleList extends PureComponent { { text: 'Alerting', value: 'alerting' }, { text: 'No Data', value: 'no_data' }, { text: 'Paused', value: 'paused' }, + { text: 'Pending', value: 'pending' }, ]; componentDidMount() { diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts index 146b7026353..ef68ddcf4a5 100644 --- a/public/app/features/alerting/AlertTabCtrl.ts +++ b/public/app/features/alerting/AlertTabCtrl.ts @@ -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() { diff --git a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap index 4ae27213e1e..b753a852e92 100644 --- a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap +++ b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap @@ -81,6 +81,12 @@ exports[`Render should render alert rules 1`] = ` > Paused + @@ -230,6 +236,12 @@ exports[`Render should render component 1`] = ` > Paused + diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index cb101672aa4..2b5e9bdf0cb 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -1,147 +1,159 @@
- -
-
-
- {{ctrl.error}} -
+
+
+
+ {{ctrl.error}} +
-
-
Alert Config
-
- Name - - Evaluate every - -
-
+
+
Alert Config
+
+ Name + +
+
+
+ Evaluate every + +
+
+ + + + 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. + +
+
+
-
-
Conditions
-
-
- - WHEN -
-
- - - OF -
-
- - -
-
- - - - -
-
- -
-
+
+
Conditions
+
+
+ + WHEN +
+
+ + + OF +
+
+ + +
+
+ + + + +
+
+ +
+
-
- -
-
+
+ +
+
-
-
- If no data or all values are null - SET STATE TO -
- -
-
+
+
+ If no data or all values are null + SET STATE TO +
+ +
+
-
- If execution error or timeout - SET STATE TO -
- -
-
+
+ If execution error or timeout + SET STATE TO +
+ +
+
-
- -
-
+
+ +
+
-
- Evaluating rule -
+
+ Evaluating rule +
-
- -
-
+
+ +
+
-
-
Notifications
-
-
- Send to - -  {{nc.name}}  - - - -
-
-
- Message - -
-
+
+
Notifications
+
+
+ Send to + +  {{nc.name}}  + + + +
+
+
+ Message + +
+
-
- -
- State history (last 50 state changes) -
+
+ +
+ State history (last 50 state changes) +
-
-
- No state changes recorded -
+
+
+ No state changes recorded +
  1. diff --git a/public/app/features/alerting/state/alertDef.ts b/public/app/features/alerting/state/alertDef.ts index 11d2aafaa7f..378be0afb91 100644 --- a/public/app/features/alerting/state/alertDef.ts +++ b/public/app/features/alerting/state/alertDef.ts @@ -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' }; diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index 16c18005204..fbe85856f31 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -32,7 +32,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, if (event.alertId) { const stateModel = alertDef.getStateDisplayModel(event.newState); titleStateClass = stateModel.stateClass; - title = ` ${stateModel.text}`; + title = ` ${stateModel.text}`; text = alertDef.getAlertAnnotationInfo(event); if (event.text) { text = text + '
    ' + event.text; diff --git a/public/app/features/annotations/event_manager.ts b/public/app/features/annotations/event_manager.ts index ef74ca193d4..db748e639a1 100644 --- a/public/app/features/annotations/event_manager.ts +++ b/public/app/features/annotations/event_manager.ts @@ -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', diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts index b1419df7376..d5695a577c5 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -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; } diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts index 20388a0450c..8f73a23bba7 100644 --- a/public/app/features/dashboard/panel_model.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -105,7 +105,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) { diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/view_state_srv.ts index 8805050831e..ff12d26233d 100644 --- a/public/app/features/dashboard/view_state_srv.ts +++ b/public/app/features/dashboard/view_state_srv.ts @@ -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) { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 238c5c917b9..5d39992c4a2 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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 { /** * 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 { range: initialRange, showingGraph: true, showingLogs: true, + showingStartPage: false, showingTable: true, supportsGraph: null, supportsLogs: null, @@ -164,7 +168,7 @@ export class Explore extends React.PureComponent { } } - 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 { 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 { datasourceLoading: false, datasourceName: datasource.name, queries: nextQueries, + showingStartPage: Boolean(StartPage), }, () => { if (datasourceError === null) { @@ -258,6 +284,7 @@ export class Explore extends React.PureComponent { }; onChangeDatasource = async option => { + const origin = this.state.datasource; this.setState({ datasource: null, datasourceError: null, @@ -266,7 +293,7 @@ export class Explore extends React.PureComponent { }); 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 { 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 { return { queryTransactions: nextQueryTransactions, + showingStartPage: false, }; }); @@ -765,16 +794,13 @@ export class Explore extends React.PureComponent { 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 { ) : undefined; const loading = queryTransactions.some(qt => !qt.done); - const showStartPages = StartPage && queryTransactions.length === 0; - const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length; return (
    @@ -889,55 +913,47 @@ export class Explore extends React.PureComponent { />
    - {showStartPages && } - {!showStartPages && ( + {showingStartPage && } + {!showingStartPage && ( <> - {viewModeCount > 1 && ( -
    - {supportsGraph ? ( - - ) : null} - {supportsTable ? ( - - ) : null} - {supportsLogs ? ( - - ) : null} -
    - )} - - {supportsGraph && - showingGraph && ( + {supportsGraph && ( + - )} - {supportsTable && showingTable ? ( -
    + + )} + {supportsTable && ( + - - ) : null} - {supportsLogs && showingLogs ? ( - - ) : null} + + )} + {supportsLogs && ( + + + + )} )} diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index 9e4fea0d3de..61cf5753b19 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -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 { } render() { - const { height = '100px', id = 'graph', loading = false } = this.props; + const { height = '100px', id = 'graph' } = this.props; const data = this.getGraphData(); return ( -
    - {loading &&
    } + <> {this.props.data && this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && !this.state.showAllTimeSeries && ( @@ -207,7 +205,7 @@ export class Graph extends PureComponent { )}
    -
    + ); } } diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index edde5acba92..9eee5c31376 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -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 { 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 { 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 { />
    -
    +
    + this.onChangeDedup(LogsDedupStrategy.none)} + small + /> + this.onChangeDedup(LogsDedupStrategy.exact)} + small + /> + this.onChangeDedup(LogsDedupStrategy.numbers)} + small + /> + this.onChangeDedup(LogsDedupStrategy.signature)} + small + /> {hasData && - data.meta && ( + meta && (
    - {data.meta.map(item => ( + {meta.map(item => (
    {item.label}: {item.value} @@ -116,33 +160,38 @@ export default class Logs extends PureComponent {
    -
    - {loading &&
    } -
    - {hasData && - data.rows.map(row => ( - -
    - {showUtc &&
    {row.timestamp}
    } - {showLocalTime &&
    {row.timeLocal}
    } - {showLabels && ( -
    - {row.labels} +
    + {hasData && + dedupedData.rows.map(row => ( + +
    + {row.duplicates > 0 && ( +
    + {Array.apply(null, { length: row.duplicates }).map(index => ( +
    + ))}
    )} -
    - +
    + {showUtc &&
    {row.timestamp}
    } + {showLocalTime &&
    {row.timeLocal}
    } + {showLabels && ( +
    + {row.labels}
    - - ))} -
    - {!loading && !hasData && 'No data was returned.'} + )} +
    + +
    + + ))}
    + {!loading && !hasData && 'No data was returned.'}
    ); } diff --git a/public/app/features/explore/Panel.tsx b/public/app/features/explore/Panel.tsx new file mode 100644 index 00000000000..dc75cb0ecca --- /dev/null +++ b/public/app/features/explore/Panel.tsx @@ -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 { + 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 ( +
    +
    +
    + +
    +
    {this.props.label}
    +
    + {isOpen && ( +
    +
    + {this.props.children} +
    + )} +
    + ); + } +} diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap index fd2010a76d3..6b9553d2e1d 100644 --- a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap @@ -1,9 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should render component 1`] = ` -
    +
    -
    +
    `; exports[`Render should render component with disclaimer 1`] = ` -
    +
    @@ -952,13 +948,11 @@ exports[`Render should render component with disclaimer 1`] = ` ] } /> -
    +
    `; exports[`Render should show query return no time series 1`] = ` -
    +
    -
    +
    `; diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts index bf09e7f37d4..eff1096df1a 100644 --- a/public/app/features/panel/metrics_tab.ts +++ b/public/app/features/panel/metrics_tab.ts @@ -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(); } diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index d3c90c2ae43..75c08f5dcd8 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -19,7 +19,7 @@ export class DatasourceSrv { this.datasources = {}; } - get(name?): Promise { + get(name?: string): Promise { if (!name) { return this.get(config.defaultDatasource); } @@ -37,7 +37,7 @@ export class DatasourceSrv { return this.loadDatasource(name); } - loadDatasource(name) { + loadDatasource(name: string): Promise { const dsConfig = config.datasources[name]; if (!dsConfig) { return this.$q.reject({ message: 'Datasource named ' + name + ' was not found' }); diff --git a/public/app/features/plugins/ds_edit_ctrl.ts b/public/app/features/plugins/ds_edit_ctrl.ts index 5e203a96e8b..d8e9f7d8e53 100644 --- a/public/app/features/plugins/ds_edit_ctrl.ts +++ b/public/app/features/plugins/ds_edit_ctrl.ts @@ -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 { diff --git a/public/app/features/teams/TeamGroupSync.tsx b/public/app/features/teams/TeamGroupSync.tsx index 939dfcc8e31..3c3561c605e 100644 --- a/public/app/features/teams/TeamGroupSync.tsx +++ b/public/app/features/teams/TeamGroupSync.tsx @@ -124,7 +124,11 @@ export class TeamGroupSync extends PureComponent {
    {headerTooltip} - + Learn more
    diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index da57bfbdfd3..f43dc44808f 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -74,7 +74,7 @@ export class TeamMembers extends PureComponent {
    - {syncEnabled ? this.renderLabels(member.labels) : null} + {syncEnabled && this.renderLabels(member.labels)} @@ -132,7 +132,7 @@ export class TeamMembers extends PureComponent { - {syncEnabled ? diff --git a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap index 5a143f19038..d486657d352 100644 --- a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap @@ -96,7 +96,7 @@ exports[`Render should render component 1`] = ` Sync LDAP or OAuth groups with your Grafana teams. Learn more diff --git a/public/app/plugins/datasource/logging/datasource.ts b/public/app/plugins/datasource/logging/datasource.ts index fcf3028c025..494dcd78d6c 100644 --- a/public/app/plugins/datasource/logging/datasource.ts +++ b/public/app/plugins/datasource/logging/datasource.ts @@ -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 { + 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'); diff --git a/public/app/plugins/datasource/logging/language_provider.test.ts b/public/app/plugins/datasource/logging/language_provider.test.ts new file mode 100644 index 00000000000..e0844cf0c7a --- /dev/null +++ b/public/app/plugins/datasource/logging/language_provider.test.ts @@ -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"}'); + }); + }); +}); diff --git a/public/app/plugins/datasource/logging/language_provider.ts b/public/app/plugins/datasource/logging/language_provider.ts index 0896168ca56..00745d2eee8 100644 --- a/public/app/plugins/datasource/logging/language_provider.ts +++ b/public/app/plugins/datasource/logging/language_provider.ts @@ -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 { + 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 { + // 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 { diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 8514b6ca7d4..0cedafdff75 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -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: diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index ac54b08526d..6e6f461d341 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -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), diff --git a/public/app/plugins/datasource/prometheus/language_utils.ts b/public/app/plugins/datasource/prometheus/language_utils.ts index 5995c427cd1..6332da26149 100644 --- a/public/app/plugins/datasource/prometheus/language_utils.ts +++ b/public/app/plugins/datasource/prometheus/language_utils.ts @@ -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}`); +} diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts index 784a8b59739..bcb8cb34082 100644 --- a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts @@ -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); }); }); diff --git a/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts index 748217e21b7..b33c0094700 100644 --- a/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts @@ -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'); + }); +}); diff --git a/public/app/plugins/panel/alertlist/editor.html b/public/app/plugins/panel/alertlist/editor.html index c48b70e02c0..a05234cea3c 100644 --- a/public/app/plugins/panel/alertlist/editor.html +++ b/public/app/plugins/panel/alertlist/editor.html @@ -50,5 +50,6 @@ + diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 01afd0716e6..c5f98792568 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -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); diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index a6c5190d937..8f63765c66d 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -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); diff --git a/public/app/plugins/panel/graph/specs/time_region_manager.test.ts b/public/app/plugins/panel/graph/specs/time_region_manager.test.ts new file mode 100644 index 00000000000..35e48897282 --- /dev/null +++ b/public/app/plugins/panel/graph/specs/time_region_manager.test.ts @@ -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()); + }); + }); + }); +}); diff --git a/public/app/plugins/panel/graph/tab_display.html b/public/app/plugins/panel/graph/tab_display.html index ebc6cf9b18e..d407f30ffc8 100644 --- a/public/app/plugins/panel/graph/tab_display.html +++ b/public/app/plugins/panel/graph/tab_display.html @@ -14,6 +14,11 @@ Thresholds ({{ctrl.panel.thresholds.length}}) +
  2. + + Time regions ({{ctrl.panel.timeRegions.length}}) + +
  3. @@ -132,4 +137,8 @@ +
    + +
    + diff --git a/public/app/plugins/panel/graph/thresholds_form.html b/public/app/plugins/panel/graph/thresholds_form.html new file mode 100644 index 00000000000..81877150a47 --- /dev/null +++ b/public/app/plugins/panel/graph/thresholds_form.html @@ -0,0 +1,77 @@ +
    +
    Thresholds
    +

    + Visual thresholds options disabled. + Visit the Alert tab update your thresholds.
    + To re-enable thresholds, the alert rule must be deleted from this panel. +

    +
    +
    +
    + +
    + +
    +
    + +
    + +
    + +
    + +
    + +
    +
    + + + +
    + + + + +
    + + + +
    + + + + +
    + +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    +
    \ No newline at end of file diff --git a/public/app/plugins/panel/graph/thresholds_form.ts b/public/app/plugins/panel/graph/thresholds_form.ts index 5f1edb8aa9a..4f480873d5b 100644 --- a/public/app/plugins/panel/graph/thresholds_form.ts +++ b/public/app/plugins/panel/graph/thresholds_form.ts @@ -58,90 +58,10 @@ export class ThresholdFormCtrl { } } -const template = ` -
    -
    Thresholds
    -

    - Visual thresholds options disabled. - Visit the Alert tab update your thresholds.
    - To re-enable thresholds, the alert rule must be deleted from this panel. -

    -
    -
    -
    - -
    - -
    -
    - -
    - -
    - -
    - -
    - -
    -
    - - - -
    - - - - -
    - - - -
    - - - - -
    - -
    - -
    - -
    -
    - -
    - -
    -
    - -
    - -
    -
    -
    -`; - coreModule.directive('graphThresholdForm', () => { return { restrict: 'E', - template: template, + templateUrl: 'public/app/plugins/panel/graph/thresholds_form.html', controller: ThresholdFormCtrl, bindToController: true, controllerAs: 'ctrl', diff --git a/public/app/plugins/panel/graph/time_region_manager.ts b/public/app/plugins/panel/graph/time_region_manager.ts new file mode 100644 index 00000000000..95987e40dbe --- /dev/null +++ b/public/app/plugins/panel/graph/time_region_manager.ts @@ -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; + } +} diff --git a/public/app/plugins/panel/graph/time_regions_form.html b/public/app/plugins/panel/graph/time_regions_form.html new file mode 100644 index 00000000000..7292c53ec80 --- /dev/null +++ b/public/app/plugins/panel/graph/time_regions_form.html @@ -0,0 +1,64 @@ +
    +
    Time regions All configured time regions refers to UTC time
    +
    +
    + +
    + +
    + +
    + +
    + + +
    + +
    + +
    + +
    + +
    + +
    +
    + + + +
    + + + + +
    + + + +
    + + + + +
    + +
    + +
    +
    + +
    + +
    +
    \ No newline at end of file diff --git a/public/app/plugins/panel/graph/time_regions_form.ts b/public/app/plugins/panel/graph/time_regions_form.ts new file mode 100644 index 00000000000..5dc9c4016eb --- /dev/null +++ b/public/app/plugins/panel/graph/time_regions_form.ts @@ -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: '=', + }, + }; +}); diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index eafa3cf23f4..26ce6b7722d 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -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 '' + valueString + ''; } @@ -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 '' + value + ''; } @@ -422,25 +424,13 @@ class SingleStatCtrl extends MetricsPanelCtrl { let body = '
    '; 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 += '
    '; diff --git a/public/app/plugins/panel/singlestat/specs/singlestat.test.ts b/public/app/plugins/panel/singlestat/specs/singlestat.test.ts index 6003acd89a6..902d722f0ad 100644 --- a/public/app/plugins/panel/singlestat/specs/singlestat.test.ts +++ b/public/app/plugins/panel/singlestat/specs/singlestat.test.ts @@ -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; diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index a7b4ea7ac20..5670d762d77 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -21,8 +21,6 @@ export interface DataSource { withCredentials: boolean; meta?: PluginMeta; pluginExports?: PluginExports; - init?: () => void; - testDatasource?: () => Promise; } export interface DataSourceSelectItem { diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 5a9db7e9b53..662835633db 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -173,6 +173,7 @@ export interface ExploreState { range: RawTimeRange; showingGraph: boolean; showingLogs: boolean; + showingStartPage?: boolean; showingTable: boolean; supportsGraph: boolean | null; supportsLogs: boolean | null; diff --git a/public/app/types/series.ts b/public/app/types/series.ts index 62b64c2e832..0ad0f6e00f8 100644 --- a/public/app/types/series.ts +++ b/public/app/types/series.ts @@ -1,4 +1,5 @@ import { Moment } from 'moment'; +import { PluginMeta } from './plugins'; export enum LoadingState { NotStarted = 'NotStarted', @@ -71,6 +72,7 @@ export interface DataQueryResponse { export interface DataQuery { refId: string; + [key: string]: any; } export interface DataQueryOptions { @@ -88,8 +90,25 @@ export interface DataQueryOptions { } export interface DataSourceApi { - query(options: DataQueryOptions): Promise; - testDatasource(): Promise; - interval?: string; + + /** + * Imports queries from a different datasource + */ + importQueries?(queries: DataQuery[], originMeta: PluginMeta): Promise; + + /** + * Initializes a datasource after instantiation + */ + init?: () => void; + + /** + * Main metrics / data query action + */ + query(options: DataQueryOptions): Promise; + + /** + * Test & verify datasource settings & connection details + */ + testDatasource(): Promise; } diff --git a/public/sass/pages/_alerting.scss b/public/sass/pages/_alerting.scss index 90f2eb526f1..77752be11bc 100644 --- a/public/sass/pages/_alerting.scss +++ b/public/sass/pages/_alerting.scss @@ -66,6 +66,13 @@ content: '\e611'; } } + + &--pending { + .panel-alert-icon:before { + color: $warn; + content: '\e611'; + } + } } @keyframes alerting-panel { diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index 58b4ca17840..edb637e5e22 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -18,10 +18,39 @@ margin-left: 15px; } - // Graph panel needs a bit extra padding at top - .panel-container { + .explore-panel { + margin-top: $panel-margin; + } + + .explore-panel__body { padding: $panel-padding; - padding-top: 10px; + } + + .explore-panel__header { + padding: $panel-padding; + padding-top: 5px; + padding-bottom: 0; + display: flex; + cursor: pointer; + margin-bottom: 5px; + transition: all 0.1s linear; + } + + .explore-panel__header:hover { + transform: translateY(-1px); + } + + .explore-panel__header-label { + font-weight: 500; + margin-right: $panel-margin; + font-size: $font-size-h6; + box-shadow: $text-shadow-faint; + } + + .explore-panel__header-buttons { + margin-right: $panel-margin; + font-size: $font-size-lg; + line-height: $font-size-h6; } // Make sure wrap buttons around on small screens @@ -91,11 +120,16 @@ height: 2px; position: relative; overflow: hidden; - background: $text-color-faint; + background: none; margin: $panel-margin / 2; + transition: background-color 1s ease; } - .explore-panel__loader:after { + .explore-panel__loader--active { + background: $text-color-faint; + } + + .explore-panel__loader--active:after { content: ' '; display: block; width: 25%; @@ -221,17 +255,18 @@ .logs-controls { display: flex; + background-color: $page-bg; + padding: $panel-padding; + padding-top: 10px; + border-radius: $border-radius; + margin: 2*$panel-margin 0; + border: $panel-border; > * { margin-right: 1em; } } - .logs-options, - .logs-graph { - margin-bottom: $panel-margin; - } - .logs-meta { flex: 1; color: $text-color-weak; @@ -265,8 +300,8 @@ .logs-row-level { background-color: transparent; - margin: 6px 0; - border-radius: 2px; + margin: 2px 0; + position: relative; opacity: 0.8; } @@ -291,6 +326,25 @@ .logs-row-level-debug { background-color: #1f78c1; } + + .logs-row-level__duplicates { + position: absolute; + width: 9px; + height: 100%; + top: 0; + left: 5px; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + align-content: flex-start; + } + + .logs-row-level__duplicate { + width: 2px; + height: 3px; + background-color: #1f78c1; + margin: 0 1px 1px 0; + } } } diff --git a/scripts/build/build-all.sh b/scripts/build/build-all.sh index 3e7058fa494..be0b297527b 100755 --- a/scripts/build/build-all.sh +++ b/scripts/build/build-all.sh @@ -32,9 +32,7 @@ echo "Build arguments: $OPT" go run build.go -goarch armv7 -cc ${CCARMV7} ${OPT} build go run build.go -goarch arm64 -cc ${CCARM64} ${OPT} build - -# MacOS build is broken atm. See Issue #13763 -#go run build.go -goos darwin -cc ${CCOSX64} ${OPT} build +go run build.go -goos darwin -cc ${CCOSX64} ${OPT} build go run build.go -goos windows -cc ${CCWIN64} ${OPT} build CC=${CCX64} go run build.go ${OPT} build diff --git a/scripts/build/publish.sh b/scripts/build/publish.sh index c03146eb910..e688748b8b1 100755 --- a/scripts/build/publish.sh +++ b/scripts/build/publish.sh @@ -2,6 +2,8 @@ # no relation to publish.go +EXTRA_OPTS="$@" + # Right now we hack this in into the publish script. # Eventually we might want to keep a list of all previous releases somewhere. _releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-3-x/10244" @@ -11,4 +13,4 @@ _whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-3/" --wn ${_whatsNewUrl} \ --rn ${_releaseNoteUrl} \ --version ${CIRCLE_TAG} \ - --apikey ${GRAFANA_COM_API_KEY} + --apikey ${GRAFANA_COM_API_KEY} ${EXTRA_OPTS} diff --git a/scripts/build/release_publisher/externalrelease.go b/scripts/build/release_publisher/externalrelease.go index 181dd4088ad..992cba38f90 100644 --- a/scripts/build/release_publisher/externalrelease.go +++ b/scripts/build/release_publisher/externalrelease.go @@ -16,23 +16,31 @@ type releaseFromExternalContent struct { func (re releaseFromExternalContent) prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error) { version := re.rawVersion[1:] - isBeta := strings.Contains(version, "beta") + beta := strings.Contains(version, "beta") + var rt ReleaseType + if beta { + rt = BETA + } else if nightly { + rt = NIGHTLY + } else { + rt = STABLE + } builds := []build{} for _, ba := range re.artifactConfigurations { - sha256, err := re.getter.getContents(fmt.Sprintf("%s.sha256", ba.getUrl(baseArchiveUrl, version, isBeta))) + sha256, err := re.getter.getContents(fmt.Sprintf("%s.sha256", ba.getUrl(baseArchiveUrl, version, rt))) if err != nil { return nil, err } - builds = append(builds, newBuild(baseArchiveUrl, ba, version, isBeta, sha256)) + builds = append(builds, newBuild(baseArchiveUrl, ba, version, rt, sha256)) } r := release{ Version: version, ReleaseDate: time.Now().UTC(), - Stable: !isBeta && !nightly, - Beta: isBeta, - Nightly: nightly, + Stable: rt.stable(), + Beta: rt.beta(), + Nightly: rt.nightly(), WhatsNewUrl: whatsNewUrl, ReleaseNotesUrl: releaseNotesUrl, Builds: builds, diff --git a/scripts/build/release_publisher/localrelease.go b/scripts/build/release_publisher/localrelease.go index 0bbecff9327..4f4575c4ff4 100644 --- a/scripts/build/release_publisher/localrelease.go +++ b/scripts/build/release_publisher/localrelease.go @@ -18,6 +18,9 @@ type releaseLocalSources struct { } func (r releaseLocalSources) prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error) { + if !nightly { + return nil, errors.New("Local releases only supported for nightly builds.") + } buildData := r.findBuilds(baseArchiveUrl) rel := release{ @@ -70,7 +73,7 @@ func createBuildWalker(path string, data *buildData, archiveTypes []buildArtifac data.version = version data.builds = append(data.builds, build{ Os: archive.os, - Url: archive.getUrl(baseArchiveUrl, version, false), + Url: archive.getUrl(baseArchiveUrl, version, NIGHTLY), Sha256: string(shaBytes), Arch: archive.arch, }) diff --git a/scripts/build/release_publisher/main.go b/scripts/build/release_publisher/main.go index 27430d4cb64..9df4888bea5 100644 --- a/scripts/build/release_publisher/main.go +++ b/scripts/build/release_publisher/main.go @@ -41,30 +41,43 @@ func main() { var builder releaseBuilder var product string + archiveProviderRoot := "https://s3-us-west-2.amazonaws.com" + buildArtifacts := completeBuildArtifactConfigurations + + if enterprise { + product = "grafana-enterprise" + baseUrl = createBaseUrl(archiveProviderRoot, "grafana-enterprise-releases", product, nightly) + var err error + buildArtifacts, err = filterBuildArtifacts([]artifactFilter{ + {os: "deb", arch: "amd64"}, + {os: "rhel", arch: "amd64"}, + {os: "linux", arch: "amd64"}, + {os: "win", arch: "amd64"}, + }) + + if err != nil { + log.Fatalf("Could not filter to the selected build artifacts, err=%v", err) + } + + } else { + product = "grafana" + baseUrl = createBaseUrl(archiveProviderRoot, "grafana-releases", product, nightly) + } + if fromLocal { path, _ := os.Getwd() builder = releaseLocalSources{ path: path, - artifactConfigurations: buildArtifactConfigurations, + artifactConfigurations: buildArtifacts, } } else { builder = releaseFromExternalContent{ getter: getHttpContents{}, rawVersion: version, - artifactConfigurations: buildArtifactConfigurations, + artifactConfigurations: buildArtifacts, } } - archiveProviderRoot := "https://s3-us-west-2.amazonaws.com" - - if enterprise { - product = "grafana-enterprise" - baseUrl = createBaseUrl(archiveProviderRoot, "grafana-enterprise-releases", product, nightly) - } else { - product = "grafana" - baseUrl = createBaseUrl(archiveProviderRoot, "grafana-releases", product, nightly) - } - p := publisher{ apiKey: apiKey, apiUri: "https://grafana.com/api", diff --git a/scripts/build/release_publisher/publisher.go b/scripts/build/release_publisher/publisher.go index d2c10d1640f..1d93c1e306e 100644 --- a/scripts/build/release_publisher/publisher.go +++ b/scripts/build/release_publisher/publisher.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/pkg/errors" "io/ioutil" "log" "net/http" @@ -61,13 +62,33 @@ func (p *publisher) postRelease(r *release) error { return nil } +type ReleaseType int + +const ( + STABLE ReleaseType = iota + 1 + BETA + NIGHTLY +) + +func (rt ReleaseType) beta() bool { + return rt == BETA +} + +func (rt ReleaseType) stable() bool { + return rt == STABLE +} + +func (rt ReleaseType) nightly() bool { + return rt == NIGHTLY +} + type buildArtifact struct { os string arch string urlPostfix string } -func (t buildArtifact) getUrl(baseArchiveUrl, version string, isBeta bool) string { +func (t buildArtifact) getUrl(baseArchiveUrl, version string, releaseType ReleaseType) string { prefix := "-" rhelReleaseExtra := "" @@ -75,7 +96,7 @@ func (t buildArtifact) getUrl(baseArchiveUrl, version string, isBeta bool) strin prefix = "_" } - if !isBeta && t.os == "rhel" { + if releaseType.stable() && t.os == "rhel" { rhelReleaseExtra = "-1" } @@ -83,7 +104,7 @@ func (t buildArtifact) getUrl(baseArchiveUrl, version string, isBeta bool) strin return url } -var buildArtifactConfigurations = []buildArtifact{ +var completeBuildArtifactConfigurations = []buildArtifact{ { os: "deb", arch: "arm64", @@ -141,10 +162,35 @@ var buildArtifactConfigurations = []buildArtifact{ }, } -func newBuild(baseArchiveUrl string, ba buildArtifact, version string, isBeta bool, sha256 string) build { +type artifactFilter struct { + os string + arch string +} + +func filterBuildArtifacts(filters []artifactFilter) ([]buildArtifact, error) { + var artifacts []buildArtifact + for _, f := range filters { + matched := false + + for _, a := range completeBuildArtifactConfigurations { + if f.os == a.os && f.arch == a.arch { + artifacts = append(artifacts, a) + matched = true + break + } + } + + if !matched { + return nil, errors.New(fmt.Sprintf("No buildArtifact for os=%v, arch=%v", f.os, f.arch)) + } + } + return artifacts, nil +} + +func newBuild(baseArchiveUrl string, ba buildArtifact, version string, rt ReleaseType, sha256 string) build { return build{ Os: ba.os, - Url: ba.getUrl(baseArchiveUrl, version, isBeta), + Url: ba.getUrl(baseArchiveUrl, version, rt), Sha256: sha256, Arch: ba.arch, } diff --git a/scripts/build/release_publisher/publisher_test.go b/scripts/build/release_publisher/publisher_test.go index 76a1446406a..2aea55d5ee1 100644 --- a/scripts/build/release_publisher/publisher_test.go +++ b/scripts/build/release_publisher/publisher_test.go @@ -4,44 +4,98 @@ import "testing" func TestPreparingReleaseFromRemote(t *testing.T) { - var builder releaseBuilder - - versionIn := "v5.2.0-beta1" - expectedVersion := "5.2.0-beta1" - whatsNewUrl := "https://whatsnews.foo/" - relNotesUrl := "https://relnotes.foo/" - expectedArch := "amd64" - expectedOs := "linux" - buildArtifacts := []buildArtifact{{expectedOs, expectedArch, ".linux-amd64.tar.gz"}} - - builder = releaseFromExternalContent{ - getter: mockHttpGetter{}, - rawVersion: versionIn, - artifactConfigurations: buildArtifactConfigurations, + cases := []struct { + version string + expectedVersion string + whatsNewUrl string + relNotesUrl string + nightly bool + expectedBeta bool + expectedStable bool + expectedArch string + expectedOs string + expectedUrl string + baseArchiveUrl string + buildArtifacts []buildArtifact + }{ + { + version: "v5.2.0-beta1", + expectedVersion: "5.2.0-beta1", + whatsNewUrl: "https://whatsnews.foo/", + relNotesUrl: "https://relnotes.foo/", + nightly: false, + expectedBeta: true, + expectedStable: false, + expectedArch: "amd64", + expectedOs: "linux", + expectedUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.0-beta1.linux-amd64.tar.gz", + baseArchiveUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", + buildArtifacts: []buildArtifact{{"linux", "amd64", ".linux-amd64.tar.gz"}}, + }, + { + version: "v5.2.3", + expectedVersion: "5.2.3", + whatsNewUrl: "https://whatsnews.foo/", + relNotesUrl: "https://relnotes.foo/", + nightly: false, + expectedBeta: false, + expectedStable: true, + expectedArch: "amd64", + expectedOs: "rhel", + expectedUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.3-1.x86_64.rpm", + baseArchiveUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", + buildArtifacts: []buildArtifact{{"rhel", "amd64", ".x86_64.rpm"}}, + }, + { + version: "v5.4.0-pre1asdf", + expectedVersion: "5.4.0-pre1asdf", + whatsNewUrl: "https://whatsnews.foo/", + relNotesUrl: "https://relnotes.foo/", + nightly: true, + expectedBeta: false, + expectedStable: false, + expectedArch: "amd64", + expectedOs: "rhel", + expectedUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.4.0-pre1asdf.x86_64.rpm", + baseArchiveUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", + buildArtifacts: []buildArtifact{{"rhel", "amd64", ".x86_64.rpm"}}, + }, } - rel, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", whatsNewUrl, relNotesUrl, false) + for _, test := range cases { + builder := releaseFromExternalContent{ + getter: mockHttpGetter{}, + rawVersion: test.version, + artifactConfigurations: test.buildArtifacts, + } - if !rel.Beta || rel.Stable { - t.Errorf("%s should have been tagged as beta (not stable), but wasn't .", versionIn) - } + rel, _ := builder.prepareRelease(test.baseArchiveUrl, test.whatsNewUrl, test.relNotesUrl, test.nightly) - if rel.Version != expectedVersion { - t.Errorf("Expected version to be %s, but it was %s.", expectedVersion, rel.Version) - } + if rel.Beta != test.expectedBeta || rel.Stable != test.expectedStable { + t.Errorf("%s should have been tagged as beta=%v, stable=%v.", test.version, test.expectedBeta, test.expectedStable) + } - expectedBuilds := len(buildArtifacts) - if len(rel.Builds) != expectedBuilds { - t.Errorf("Expected %v builds, but got %v.", expectedBuilds, len(rel.Builds)) - } + if rel.Version != test.expectedVersion { + t.Errorf("Expected version to be %s, but it was %s.", test.expectedVersion, rel.Version) + } - build := rel.Builds[0] - if build.Arch != expectedArch { - t.Errorf("Expected arch to be %v, but it was %v", expectedArch, build.Arch) - } + expectedBuilds := len(test.buildArtifacts) + if len(rel.Builds) != expectedBuilds { + t.Errorf("Expected %v builds, but got %v.", expectedBuilds, len(rel.Builds)) + } - if build.Os != expectedOs { - t.Errorf("Expected arch to be %v, but it was %v", expectedOs, build.Os) + build := rel.Builds[0] + if build.Arch != test.expectedArch { + t.Errorf("Expected arch to be %v, but it was %v", test.expectedArch, build.Arch) + } + + if build.Os != test.expectedOs { + t.Errorf("Expected os to be %v, but it was %v", test.expectedOs, build.Os) + } + + if build.Url != test.expectedUrl { + t.Errorf("Expected url to be %v, but it was %v", test.expectedUrl, build.Url) + } } } @@ -61,7 +115,7 @@ func TestPreparingReleaseFromLocal(t *testing.T) { testDataPath := "testdata" builder = releaseLocalSources{ path: testDataPath, - artifactConfigurations: buildArtifactConfigurations, + artifactConfigurations: completeBuildArtifactConfigurations, } relAll, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-enterprise-releases/master/grafana-enterprise", whatsNewUrl, relNotesUrl, true) @@ -116,4 +170,31 @@ func TestPreparingReleaseFromLocal(t *testing.T) { if build.Os != expectedOs { t.Fatalf("Expected os to be %s, but was %s", expectedOs, build.Os) } + + _, err := builder.prepareRelease("", "", "", false) + if err == nil { + t.Error("Error was nil, but expected an error as the local releaser only supports nightly builds.") + } +} + +func TestFilterBuildArtifacts(t *testing.T) { + buildArtifacts, _ := filterBuildArtifacts([]artifactFilter{ + {os: "deb", arch: "amd64"}, + {os: "rhel", arch: "amd64"}, + {os: "linux", arch: "amd64"}, + {os: "win", arch: "amd64"}, + }) + + if len(buildArtifacts) != 4 { + t.Errorf("Expected 4 build artifacts after filtering, but was %v", len(buildArtifacts)) + } + + _, err := filterBuildArtifacts([]artifactFilter{ + {os: "foobar", arch: "amd64"}, + }) + + if err == nil { + t.Errorf("Expected an error as a we tried to filter on a nonexiststant os.") + } + }
    {member.login} {member.email} this.onRemoveMember(member)} /> Name Email : ''} + {syncEnabled && }