diff --git a/.circleci/config.yml b/.circleci/config.yml index ec1fcfb411f..876db1d4823 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -323,7 +323,7 @@ jobs: deploy-enterprise-master: docker: - - image: grafana/grafana-ci-deploy:1.1.0 + - image: grafana/grafana-ci-deploy:1.2.0 steps: - attach_workspace: at: . @@ -346,7 +346,7 @@ jobs: deploy-enterprise-release: docker: - - image: grafana/grafana-ci-deploy:1.1.0 + - image: grafana/grafana-ci-deploy:1.2.0 steps: - attach_workspace: at: . @@ -370,15 +370,15 @@ jobs: command: './scripts/build/load-signing-key.sh' - run: name: Update Debian repository - command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"' + command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"' - run: name: Update RPM repository - command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"' + command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"' deploy-master: docker: - - image: grafana/grafana-ci-deploy:1.1.0 + - image: grafana/grafana-ci-deploy:1.2.0 steps: - attach_workspace: at: . @@ -408,7 +408,7 @@ jobs: deploy-release: docker: - - image: grafana/grafana-ci-deploy:1.1.0 + - image: grafana/grafana-ci-deploy:1.2.0 steps: - checkout - attach_workspace: @@ -433,10 +433,10 @@ jobs: command: './scripts/build/load-signing-key.sh' - run: name: Update Debian repository - command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"' + command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"' - run: name: Update RPM repository - command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"' + command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"' workflows: version: 2 diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0339d1991..9225d6545e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ ### Bug fixes * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486) +* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795) # 5.4.3 (2019-01-14) diff --git a/devenv/dev-dashboards/panel_tests_gauge.json b/devenv/dev-dashboards/panel_tests_gauge.json new file mode 100644 index 00000000000..c6e81ececc8 --- /dev/null +++ b/devenv/dev-dashboards/panel_tests_gauge.json @@ -0,0 +1,1250 @@ +{ + "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, + "iteration": 1547810606599, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 11, + "panels": [], + "title": "Value options tests", + "type": "row" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 5, + "x": 0, + "y": 1 + }, + "id": 2, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "2", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "ms", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Average, 2 decimals, ms unit", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 6, + "x": 5, + "y": 1 + }, + "id": 5, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "max", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "ms", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Max (90 ms), no decimals", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 5, + "x": 11, + "y": 1 + }, + "id": 6, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "p", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "s", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Current (10 ms), no unit, prefix (p), suffix (s)", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 4, + "w": 3, + "x": 16, + "y": 1 + }, + "id": 16, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 1 + }, + "id": 18, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10,91" + } + ], + "timeFrom": "1h", + "timeShift": null, + "title": "", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 4, + "w": 3, + "x": 16, + "y": 5 + }, + "id": 17, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 5 + }, + "id": 19, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10,81" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "", + "type": "gauge" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 15, + "panels": [], + "title": "Value Mappings", + "type": "row" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 4, + "x": 0, + "y": 10 + }, + "id": 12, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [ + { + "from": "", + "id": 1, + "operator": "", + "text": "TEN", + "to": "", + "type": 1, + "value": "10" + } + ] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "value mapping 10 -> TEN", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "description": "should read N/A", + "gridPos": { + "h": 8, + "w": 4, + "x": 4, + "y": 10 + }, + "id": 13, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [ + { + "from": "", + "id": 1, + "operator": "", + "text": "N/A", + "to": "", + "type": 1, + "value": "null" + } + ] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10,null,null,null,null" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "value mapping null -> N/A", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "description": "should read N/A", + "gridPos": { + "h": 8, + "w": 6, + "x": 8, + "y": 10 + }, + "id": 20, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [ + { + "from": "0", + "id": 1, + "operator": "", + "text": "OK", + "to": "10", + "type": 2, + "value": "null" + } + ] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10,null,null,null,null,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "value mapping range, 0-10 -> OK, value 10", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "description": "should read N/A", + "gridPos": { + "h": 8, + "w": 6, + "x": 14, + "y": 10 + }, + "id": 21, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "current", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [ + { + "from": "0", + "id": 1, + "operator": "", + "text": "OK", + "to": "90", + "type": 2, + "value": "null" + }, + { + "from": "90", + "id": 2, + "operator": "", + "text": "BAD", + "to": "100", + "type": 2, + "value": "" + } + ] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10,null,null,null,null,10,95" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "value mapping range, 90-100 -> BAD, value 90", + "type": "gauge" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 9, + "panels": [], + "title": "Templating & Repeat", + "type": "row" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 19 + }, + "id": 7, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "2", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "$Servers", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "ms", + "valueMappings": [] + }, + "repeat": "Servers", + "repeatDirection": "h", + "scopedVars": { + "Servers": { + "selected": false, + "text": "server1", + "value": "server1" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "repeat $Servers", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 19 + }, + "id": 22, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "2", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "$Servers", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "ms", + "valueMappings": [] + }, + "repeat": null, + "repeatDirection": "h", + "repeatIteration": 1547810606599, + "repeatPanelId": 7, + "scopedVars": { + "Servers": { + "selected": false, + "text": "server2", + "value": "server2" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "repeat $Servers", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 19 + }, + "id": 23, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "2", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "$Servers", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "ms", + "valueMappings": [] + }, + "repeat": null, + "repeatDirection": "h", + "repeatIteration": 1547810606599, + "repeatPanelId": 7, + "scopedVars": { + "Servers": { + "selected": false, + "text": "server3", + "value": "server3" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "repeat $Servers", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 19 + }, + "id": 24, + "links": [], + "nullPointMode": "null", + "options-gauge": { + "baseColor": "#299c46", + "decimals": "2", + "maxValue": 100, + "minValue": 0, + "options": { + "baseColor": "#299c46", + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "$Servers", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [ + { + "color": "#e24d42", + "index": 2, + "value": 90 + }, + { + "color": "#ef843c", + "index": 1, + "value": 75 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "ms", + "valueMappings": [] + }, + "repeat": null, + "repeatDirection": "h", + "repeatIteration": 1547810606599, + "repeatPanelId": 7, + "scopedVars": { + "Servers": { + "selected": false, + "text": "server4", + "value": "server4" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "repeat $Servers", + "type": "gauge" + } + ], + "refresh": false, + "schemaVersion": 17, + "style": "dark", + "tags": [ + "gdev", + "panel-tests" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": { + "selected": true, + "tags": [], + "text": "All", + "value": [ + "$__all" + ] + }, + "hide": 0, + "includeAll": true, + "label": null, + "multi": true, + "name": "Servers", + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "server1", + "value": "server1" + }, + { + "selected": false, + "text": "server2", + "value": "server2" + }, + { + "selected": false, + "text": "server3", + "value": "server3" + }, + { + "selected": false, + "text": "server4", + "value": "server4" + } + ], + "query": "server1,server2,server3,server4", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-1h", + "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": "", + "title": "Panel Tests - Gauge", + "uid": "_5rDmaQiz", + "version": 5 +} diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md index 541aed3fd1f..b6028b0a2a7 100644 --- a/docs/sources/auth/gitlab.md +++ b/docs/sources/auth/gitlab.md @@ -47,7 +47,7 @@ authentication: ```bash [auth.gitlab] -enabled = false +enabled = true allow_sign_up = false client_id = GITLAB_APPLICATION_ID client_secret = GITLAB_SECRET diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index e2bcb50bb1d..22f9f38c854 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -38,7 +38,7 @@ Name | Description ### IAM Roles -Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If you grafana +Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If your Grafana server is running on AWS you can use IAM Roles and authentication will be handled automatically. Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index cf1657e1c83..eb50944ad35 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -1,4 +1,5 @@ import React, { PureComponent } from 'react'; +import _ from 'lodash'; import Scrollbars from 'react-custom-scrollbars'; interface Props { @@ -6,7 +7,11 @@ interface Props { autoHide?: boolean; autoHideTimeout?: number; autoHideDuration?: number; + autoMaxHeight?: string; hideTracksWhenNotNeeded?: boolean; + scrollTop?: number; + setScrollTop: (value: React.MouseEvent) => void; + autoHeightMin?: number | string; } /** @@ -18,26 +23,55 @@ export class CustomScrollbar extends PureComponent { autoHide: true, autoHideTimeout: 200, autoHideDuration: 200, + autoMaxHeight: '100%', hideTracksWhenNotNeeded: false, + setScrollTop: () => {}, + autoHeightMin: '0' }; + private ref: React.RefObject; + + constructor(props: Props) { + super(props); + this.ref = React.createRef(); + } + + updateScroll() { + const ref = this.ref.current; + + if (ref && !_.isNil(this.props.scrollTop)) { + if (this.props.scrollTop > 10000) { + ref.scrollToBottom(); + } else { + ref.scrollTop(this.props.scrollTop); + } + } + } + + componentDidMount() { + this.updateScroll(); + } + + componentDidUpdate() { + this.updateScroll(); + } + render() { - const { customClassName, children, ...scrollProps } = this.props; + const { customClassName, children, autoMaxHeight } = this.props; return (
} renderTrackVertical={props =>
} renderThumbHorizontal={props =>
} renderThumbVertical={props =>
} renderView={props =>
} - {...scrollProps} > {children} diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap index 0a7de5fcffe..aabe3dd98c5 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -7,7 +7,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` Object { "height": "auto", "maxHeight": "100%", - "minHeight": "0", + "minHeight": 0, "overflow": "hidden", "position": "relative", "width": "100%", @@ -24,7 +24,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` "marginBottom": 0, "marginRight": 0, "maxHeight": "calc(100% + 0px)", - "minHeight": "calc(0 + 0px)", + "minHeight": 0, "overflow": "scroll", "position": "relative", "right": undefined, @@ -42,9 +42,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` Object { "display": "none", "height": 6, - "opacity": 0, "position": "absolute", - "transition": "opacity 200ms", } } > @@ -64,9 +62,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` style={ Object { "display": "none", - "opacity": 0, "position": "absolute", - "transition": "opacity 200ms", "width": 6, } } diff --git a/packages/grafana-ui/src/components/FormField/FormField.test.tsx b/packages/grafana-ui/src/components/FormField/FormField.test.tsx new file mode 100644 index 00000000000..3c89a347e86 --- /dev/null +++ b/packages/grafana-ui/src/components/FormField/FormField.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { FormField, Props } from './FormField'; + +const setup = (propOverrides?: object) => { + const props: Props = { + label: 'Test', + labelWidth: 11, + value: 10, + onChange: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/grafana-ui/src/components/FormField/FormField.tsx b/packages/grafana-ui/src/components/FormField/FormField.tsx new file mode 100644 index 00000000000..593678c7383 --- /dev/null +++ b/packages/grafana-ui/src/components/FormField/FormField.tsx @@ -0,0 +1,25 @@ +import React, { InputHTMLAttributes, FunctionComponent } from 'react'; +import { FormLabel } from '..'; + +export interface Props extends InputHTMLAttributes { + label: string; + labelWidth?: number; + inputWidth?: number; +} + +const defaultProps = { + labelWidth: 6, + inputWidth: 12, +}; + +const FormField: FunctionComponent = ({ label, labelWidth, inputWidth, ...inputProps }) => { + return ( +
+ {label} + +
+ ); +}; + +FormField.defaultProps = defaultProps; +export { FormField }; diff --git a/packages/grafana-ui/src/components/FormField/_FormField.scss b/packages/grafana-ui/src/components/FormField/_FormField.scss new file mode 100644 index 00000000000..36955e2fca6 --- /dev/null +++ b/packages/grafana-ui/src/components/FormField/_FormField.scss @@ -0,0 +1,12 @@ +.form-field { + margin-bottom: $gf-form-margin; + display: flex; + flex-direction: row; + align-items: center; + text-align: left; + position: relative; + + &--grow { + flex-grow: 1; + } +} diff --git a/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap new file mode 100644 index 00000000000..99eb0803149 --- /dev/null +++ b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+ + Test + + +
+`; diff --git a/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx new file mode 100644 index 00000000000..2bd4fbc153b --- /dev/null +++ b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx @@ -0,0 +1,42 @@ +import React, { FunctionComponent, ReactNode } from 'react'; +import classNames from 'classnames'; +import { Tooltip } from '..'; + +interface Props { + children: ReactNode; + className?: string; + htmlFor?: string; + isFocused?: boolean; + isInvalid?: boolean; + tooltip?: string; + width?: number; +} + +export const FormLabel: FunctionComponent = ({ + children, + isFocused, + isInvalid, + className, + htmlFor, + tooltip, + width, + ...rest +}) => { + const classes = classNames(`gf-form-label width-${width ? width : '10'}`, className, { + 'gf-form-label--is-focused': isFocused, + 'gf-form-label--is-invalid': isInvalid, + }); + + return ( + + ); +}; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx new file mode 100644 index 00000000000..b3396841d4d --- /dev/null +++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Gauge, Props } from './Gauge'; +import { TimeSeriesVMs } from '../../types/series'; +import { ValueMapping, MappingType } from '../../types'; + +jest.mock('jquery', () => ({ + plot: jest.fn(), +})); + +const setup = (propOverrides?: object) => { + const props: Props = { + maxValue: 100, + valueMappings: [], + minValue: 0, + prefix: '', + showThresholdMarkers: true, + showThresholdLabels: false, + suffix: '', + thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }], + unit: 'none', + stat: 'avg', + height: 300, + width: 300, + timeSeries: {} as TimeSeriesVMs, + decimals: 0, + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as Gauge; + + return { + instance, + wrapper, + }; +}; + +describe('Get font color', () => { + it('should get first threshold color when only one threshold', () => { + const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] }); + + expect(instance.getFontColor(49)).toEqual('#7EB26D'); + }); + + it('should get the threshold color if value is same as a threshold', () => { + const { instance } = setup({ + thresholds: [ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ], + }); + + expect(instance.getFontColor(50)).toEqual('#EAB839'); + }); + + it('should get the nearest threshold color between thresholds', () => { + const { instance } = setup({ + thresholds: [ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ], + }); + + expect(instance.getFontColor(55)).toEqual('#EAB839'); + }); +}); + +describe('Get thresholds formatted', () => { + it('should return first thresholds color for min and max', () => { + const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] }); + + expect(instance.getFormattedThresholds()).toEqual([ + { value: 0, color: '#7EB26D' }, + { value: 100, color: '#7EB26D' }, + ]); + }); + + it('should get the correct formatted values when thresholds are added', () => { + const { instance } = setup({ + thresholds: [ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ], + }); + + expect(instance.getFormattedThresholds()).toEqual([ + { value: 0, color: '#7EB26D' }, + { value: 50, color: '#7EB26D' }, + { value: 75, color: '#EAB839' }, + { value: 100, color: '#6ED0E0' }, + ]); + }); +}); + +describe('Format value with value mappings', () => { + it('should return undefined with no valuemappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '10'; + const { instance } = setup({ valueMappings }); + + const result = instance.getFirstFormattedValueMapping(valueMappings, value); + + expect(result).toBeUndefined(); + }); + + it('should return undefined with no matching valuemappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, + ]; + const value = '10'; + const { instance } = setup({ valueMappings }); + + const result = instance.getFirstFormattedValueMapping(valueMappings, value); + + expect(result).toBeUndefined(); + }); + + it('should return first matching mapping with lowest id', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' }, + ]; + const value = '10'; + const { instance } = setup({ valueMappings }); + + const result = instance.getFirstFormattedValueMapping(valueMappings, value); + + expect(result.text).toEqual('1-20'); + }); + + it('should return rangeToText mapping where value equals to', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + const { instance } = setup({ valueMappings }); + + const result = instance.getFirstFormattedValueMapping(valueMappings, value); + + expect(result.text).toEqual('1-10'); + }); + + it('should return rangeToText mapping where value equals from', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + const { instance } = setup({ valueMappings }); + + const result = instance.getFirstFormattedValueMapping(valueMappings, value); + + expect(result.text).toEqual('10-20'); + }); + + it('should return rangeToText mapping where value is between from and to', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + const { instance } = setup({ valueMappings }); + + const result = instance.getFirstFormattedValueMapping(valueMappings, value); + + expect(result.text).toEqual('1-20'); + }); +}); + +describe('Format value', () => { + it('should return if value isNaN', () => { + const valueMappings: ValueMapping[] = []; + const value = 'N/A'; + const { instance } = setup({ valueMappings }); + + const result = instance.formatValue(value); + + expect(result).toEqual('N/A'); + }); + + it('should return formatted value if there are no value mappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '6'; + const { instance } = setup({ valueMappings, decimals: 1 }); + + const result = instance.formatValue(value); + + expect(result).toEqual(' 6.0 '); + }); + + it('should return formatted value if there are no matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, + ]; + const value = '10'; + const { instance } = setup({ valueMappings, decimals: 1 }); + + const result = instance.formatValue(value); + + expect(result).toEqual(' 10.0 '); + }); + + it('should return mapped value if there are matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '11'; + const { instance } = setup({ valueMappings, decimals: 1 }); + + const result = instance.formatValue(value); + + expect(result).toEqual(' 1-20 '); + }); +}); diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx new file mode 100644 index 00000000000..63d875e9cd5 --- /dev/null +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -0,0 +1,284 @@ +import React, { PureComponent } from 'react'; +import $ from 'jquery'; + +import { + ValueMapping, + Threshold, + ThemeName, + MappingType, + BasicGaugeColor, + ThemeNames, + ValueMap, + RangeMap, +} from '../../types/panel'; +import { TimeSeriesVMs } from '../../types/series'; +import { getValueFormat } from '../../utils/valueFormats/valueFormats'; + +type TimeSeriesValue = string | number | null; + +export interface Props { + decimals: number; + height: number; + valueMappings: ValueMapping[]; + maxValue: number; + minValue: number; + prefix: string; + timeSeries: TimeSeriesVMs; + thresholds: Threshold[]; + showThresholdMarkers: boolean; + showThresholdLabels: boolean; + stat: string; + suffix: string; + unit: string; + width: number; + theme?: ThemeName; +} + +export class Gauge extends PureComponent { + canvasElement: any; + + static defaultProps = { + maxValue: 100, + valueMappings: [], + minValue: 0, + prefix: '', + showThresholdMarkers: true, + showThresholdLabels: false, + suffix: '', + thresholds: [], + unit: 'none', + stat: 'avg', + theme: ThemeNames.Dark, + }; + + componentDidMount() { + this.draw(); + } + + componentDidUpdate() { + this.draw(); + } + + addValueToTextMappingText(allValueMappings: ValueMapping[], valueToTextMapping: ValueMap, value: TimeSeriesValue) { + if (!valueToTextMapping.value) { + return allValueMappings; + } + + const valueAsNumber = parseFloat(value as string); + const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string); + + if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) { + return allValueMappings; + } + + if (valueAsNumber !== valueToTextMappingAsNumber) { + return allValueMappings; + } + + return allValueMappings.concat(valueToTextMapping); + } + + addRangeToTextMappingText(allValueMappings: ValueMapping[], rangeToTextMapping: RangeMap, value: TimeSeriesValue) { + if (!rangeToTextMapping.from || !rangeToTextMapping.to || !value) { + return allValueMappings; + } + + const valueAsNumber = parseFloat(value as string); + const fromAsNumber = parseFloat(rangeToTextMapping.from as string); + const toAsNumber = parseFloat(rangeToTextMapping.to as string); + + if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) { + return allValueMappings; + } + + if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) { + return allValueMappings.concat(rangeToTextMapping); + } + + return allValueMappings; + } + + getAllFormattedValueMappings(valueMappings: ValueMapping[], value: TimeSeriesValue) { + const allFormattedValueMappings = valueMappings.reduce( + (allValueMappings, valueMapping) => { + if (valueMapping.type === MappingType.ValueToText) { + allValueMappings = this.addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value); + } else if (valueMapping.type === MappingType.RangeToText) { + allValueMappings = this.addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value); + } + + return allValueMappings; + }, + [] as ValueMapping[] + ); + + allFormattedValueMappings.sort((t1, t2) => { + return t1.id - t2.id; + }); + + return allFormattedValueMappings; + } + + getFirstFormattedValueMapping(valueMappings: ValueMapping[], value: TimeSeriesValue) { + return this.getAllFormattedValueMappings(valueMappings, value)[0]; + } + + formatValue(value: TimeSeriesValue) { + const { decimals, valueMappings, prefix, suffix, unit } = this.props; + + if (isNaN(value as number)) { + return value; + } + + if (valueMappings.length > 0) { + const valueMappedValue = this.getFirstFormattedValueMapping(valueMappings, value); + if (valueMappedValue) { + return `${prefix} ${valueMappedValue.text} ${suffix}`; + } + } + + const formatFunc = getValueFormat(unit); + const formattedValue = formatFunc(value as number, decimals); + + return `${prefix} ${formattedValue} ${suffix}`; + } + + getFontColor(value: TimeSeriesValue) { + const { thresholds } = this.props; + + if (thresholds.length === 1) { + return thresholds[0].color; + } + + const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0]; + if (atThreshold) { + return atThreshold.color; + } + + const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value); + + if (belowThreshold.length > 0) { + const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0]; + return nearestThreshold.color; + } + + return BasicGaugeColor.Red; + } + + getFormattedThresholds() { + const { maxValue, minValue, thresholds } = this.props; + + const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index); + const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1]; + + const formattedThresholds = [ + ...thresholdsSortedByIndex.map(threshold => { + if (threshold.index === 0) { + return { value: minValue, color: threshold.color }; + } + + const previousThreshold = thresholdsSortedByIndex[threshold.index - 1]; + return { value: threshold.value, color: previousThreshold.color }; + }), + { value: maxValue, color: lastThreshold.color }, + ]; + + return formattedThresholds; + } + + draw() { + const { + maxValue, + minValue, + timeSeries, + showThresholdLabels, + showThresholdMarkers, + width, + height, + stat, + theme, + } = this.props; + + let value: TimeSeriesValue = ''; + + if (timeSeries[0]) { + value = timeSeries[0].stats[stat]; + } else { + value = 'N/A'; + } + + const dimension = Math.min(width, height * 1.3); + const backgroundColor = theme === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)'; + const fontScale = parseInt('80', 10) / 100; + const fontSize = Math.min(dimension / 5, 100) * fontScale; + const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1; + const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio; + const thresholdMarkersWidth = gaugeWidth / 5; + const thresholdLabelFontSize = fontSize / 2.5; + + const options = { + series: { + gauges: { + gauge: { + min: minValue, + max: maxValue, + background: { color: backgroundColor }, + border: { color: null }, + shadow: { show: false }, + width: gaugeWidth, + }, + frame: { show: false }, + label: { show: false }, + layout: { margin: 0, thresholdWidth: 0 }, + cell: { border: { width: 0 } }, + threshold: { + values: this.getFormattedThresholds(), + label: { + show: showThresholdLabels, + margin: thresholdMarkersWidth + 1, + font: { size: thresholdLabelFontSize }, + }, + show: showThresholdMarkers, + width: thresholdMarkersWidth, + }, + value: { + color: this.getFontColor(value), + formatter: () => { + return this.formatValue(value); + }, + font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' }, + }, + show: true, + }, + }, + }; + + const plotSeries = { data: [[0, value]] }; + + try { + $.plot(this.canvasElement, [plotSeries], options); + } catch (err) { + console.log('Gauge rendering error', err, options, timeSeries); + } + } + + render() { + const { height, width } = this.props; + + return ( +
+
(this.canvasElement = element)} + /> +
+ ); + } +} + +export default Gauge; diff --git a/packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx b/packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx deleted file mode 100644 index 8b80de64696..00000000000 --- a/packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { SFC, ReactNode } from 'react'; -import classNames from 'classnames'; - -interface Props { - children: ReactNode; - htmlFor?: string; - className?: string; - isFocused?: boolean; - isInvalid?: boolean; -} - -export const GfFormLabel: SFC = ({ children, isFocused, isInvalid, className, htmlFor, ...rest }) => { - const classes = classNames('gf-form-label', className, { - 'gf-form-label--is-focused': isFocused, - 'gf-form-label--is-invalid': isInvalid, - }); - - return ( - - ); -}; diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss index 9f5d4f02695..87d5b00f3b1 100644 --- a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss +++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss @@ -6,7 +6,7 @@ } .panel-options-group__header { - padding: 4px 20px; + padding: 4px 8px; font-size: 1.1rem; background: $panel-options-group-header-bg; position: relative; diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index b3b0c8efbbb..5246c7cbf15 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -16,7 +16,7 @@ import SelectOptionGroup from './SelectOptionGroup'; import IndicatorsContainer from './IndicatorsContainer'; import NoOptionsMessage from './NoOptionsMessage'; import resetSelectStyles from './resetSelectStyles'; -import { CustomScrollbar } from '@grafana/ui'; +import { CustomScrollbar } from '..'; export interface SelectOptionItem { label?: string; @@ -61,7 +61,7 @@ interface AsyncProps { export const MenuList = (props: any) => { return ( - {props.children} + {props.children} ); }; @@ -202,7 +202,7 @@ export class AsyncSelect extends PureComponent { classNamePrefix="gf-form-select-box" className={selectClassNames} components={{ - Option, + Option: SelectOption, SingleValue, IndicatorsContainer, NoOptionsMessage, diff --git a/packages/grafana-ui/src/components/Select/_Select.scss b/packages/grafana-ui/src/components/Select/_Select.scss index bf18125d7b8..bc18ed9d369 100644 --- a/packages/grafana-ui/src/components/Select/_Select.scss +++ b/packages/grafana-ui/src/components/Select/_Select.scss @@ -102,6 +102,7 @@ $select-input-bg-disabled: $input-bg-disabled; .gf-form-select-box__value-container { display: table-cell; padding: 6px 10px; + vertical-align: middle; > div { display: inline-block; } diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx index 14f84e00f80..845ff5f6bf4 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ThresholdsEditor, Props } from './ThresholdsEditor'; -import { BasicGaugeColor } from '../../types'; const setup = (propOverrides?: object) => { const props: Props = { @@ -15,49 +14,160 @@ const setup = (propOverrides?: object) => { return shallow().instance() as ThresholdsEditor; }; +describe('Initialization', () => { + it('should add a base threshold if missing', () => { + const instance = setup(); + + expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]); + }); +}); + describe('Add threshold', () => { - it('should add threshold', () => { + it('should not add threshold at index 0', () => { const instance = setup(); instance.onAddThreshold(0); - expect(instance.state.thresholds).toEqual([{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }]); + expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]); }); - it('should add another threshold above a first', () => { - const instance = setup({ - thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }], - }); + it('should add threshold', () => { + const instance = setup(); instance.onAddThreshold(1); expect(instance.state.thresholds).toEqual([ - { index: 1, value: 75, color: 'rgb(170, 95, 61)' }, - { index: 0, value: 50, color: 'rgb(127, 115, 64)' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]); + }); + + it('should add another threshold above a first', () => { + const instance = setup({ + thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }], + }); + + instance.onAddThreshold(2); + + expect(instance.state.thresholds).toEqual([ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]); + }); + + it('should add another threshold between first and second index', () => { + const instance = setup({ + thresholds: [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ], + }); + + instance.onAddThreshold(2); + + expect(instance.state.thresholds).toEqual([ + { index: 3, value: 75, color: '#6ED0E0' }, + { index: 2, value: 62.5, color: '#EF843C' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]); + }); +}); + +describe('Remove threshold', () => { + it('should not remove threshold at index 0', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + const instance = setup({ thresholds }); + + instance.onRemoveThreshold(thresholds[0]); + + expect(instance.state.thresholds).toEqual(thresholds); + }); + + it('should remove threshold', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + const instance = setup({ + thresholds, + }); + + instance.onRemoveThreshold(thresholds[1]); + + expect(instance.state.thresholds).toEqual([ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 75, color: '#6ED0E0' }, ]); }); }); describe('change threshold value', () => { - it('should update value and resort rows', () => { + it('should not change threshold at index 0', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + const instance = setup({ thresholds }); + + const mockEvent = { target: { value: 12 } }; + + instance.onChangeThresholdValue(mockEvent, thresholds[0]); + + expect(instance.state.thresholds).toEqual(thresholds); + }); + + it('should update value', () => { const instance = setup(); - const mockThresholds = [ - { index: 0, value: 50, color: 'rgba(237, 129, 40, 0.89)' }, - { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' }, + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, ]; instance.state = { - baseColor: BasicGaugeColor.Green, - thresholds: mockThresholds, + thresholds, }; const mockEvent = { target: { value: 78 } }; - instance.onChangeThresholdValue(mockEvent, mockThresholds[0]); + instance.onChangeThresholdValue(mockEvent, thresholds[1]); expect(instance.state.thresholds).toEqual([ - { index: 0, value: 78, color: 'rgba(237, 129, 40, 0.89)' }, - { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 78, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]); + }); +}); + +describe('on blur threshold value', () => { + it('should resort rows and update indexes', () => { + const instance = setup(); + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 78, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + + instance.state = { + thresholds, + }; + + instance.onBlur(); + + expect(instance.state.thresholds).toEqual([ + { index: 2, value: 78, color: '#EAB839' }, + { index: 1, value: 75, color: '#6ED0E0' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, ]); }); }); diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx index c635b9cb4f5..590aca5c7a1 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx @@ -1,9 +1,10 @@ import React, { PureComponent } from 'react'; -import tinycolor, { ColorInput } from 'tinycolor2'; +// import tinycolor, { ColorInput } from 'tinycolor2'; -import { Threshold, BasicGaugeColor } from '../../types'; +import { Threshold } from '../../types'; import { ColorPicker } from '../ColorPicker/ColorPicker'; import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup'; +import { colors } from '../../utils'; export interface Props { thresholds: Threshold[]; @@ -12,50 +13,49 @@ export interface Props { interface State { thresholds: Threshold[]; - baseColor: string; } export class ThresholdsEditor extends PureComponent { constructor(props: Props) { super(props); - this.state = { thresholds: props.thresholds, baseColor: BasicGaugeColor.Green }; + const addDefaultThreshold = this.props.thresholds.length === 0; + const thresholds: Threshold[] = addDefaultThreshold + ? [{ index: 0, value: -Infinity, color: colors[0] }] + : props.thresholds; + this.state = { thresholds }; + + if (addDefaultThreshold) { + this.onChange(); + } } onAddThreshold = (index: number) => { - const maxValue = 100; // hardcoded for now before we add the base threshold - const minValue = 0; // hardcoded for now before we add the base threshold const { thresholds } = this.state; + const maxValue = 100; + const minValue = 0; + + if (index === 0) { + return; + } const newThresholds = thresholds.map(threshold => { if (threshold.index >= index) { - threshold = { - ...threshold, - index: threshold.index + 1, - }; + const index = threshold.index + 1; + threshold = { ...threshold, index }; } - return threshold; }); // Setting value to a value between the previous thresholds - let value; + const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0]; + const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0]; + const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue; + const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue; + const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2; - if (index === 0 && thresholds.length === 0) { - value = maxValue - (maxValue - minValue) / 2; - } else if (index === 0 && thresholds.length > 0) { - value = newThresholds[index + 1].value - (newThresholds[index + 1].value - minValue) / 2; - } else if (index > newThresholds[newThresholds.length - 1].index) { - value = maxValue - (maxValue - newThresholds[index - 1].value) / 2; - } - - // Set a color that lies between the previous thresholds - let color; - if (index === 0 && thresholds.length === 0) { - color = tinycolor.mix(BasicGaugeColor.Green, BasicGaugeColor.Red, 50).toRgbString(); - } else { - color = tinycolor.mix(thresholds[index - 1].color as ColorInput, BasicGaugeColor.Red, 50).toRgbString(); - } + // Set a color + const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0]; this.setState( { @@ -68,23 +68,45 @@ export class ThresholdsEditor extends PureComponent { }, ]), }, - () => this.updateGauge() + () => this.onChange() ); }; onRemoveThreshold = (threshold: Threshold) => { + if (threshold.index === 0) { + return; + } + this.setState( - prevState => ({ thresholds: prevState.thresholds.filter(t => t !== threshold) }), - () => this.updateGauge() + prevState => { + const newThresholds = prevState.thresholds.map(t => { + if (t.index > threshold.index) { + const index = t.index - 1; + t = { ...t, index }; + } + return t; + }); + + return { + thresholds: newThresholds.filter(t => t !== threshold), + }; + }, + () => this.onChange() ); }; onChangeThresholdValue = (event: any, threshold: Threshold) => { + if (threshold.index === 0) { + return; + } + const { thresholds } = this.state; + const parsedValue = parseInt(event.target.value, 10); + const value = isNaN(parsedValue) ? null : parsedValue; const newThresholds = thresholds.map(t => { - if (t === threshold) { - t = { ...t, value: event.target.value }; + if (t === threshold && t.index !== 0) { + t = { ...t, value: value as number }; } return t; @@ -108,18 +130,24 @@ export class ThresholdsEditor extends PureComponent { { thresholds: newThresholds, }, - () => this.updateGauge() + () => this.onChange() ); }; - onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds); onBlur = () => { - this.setState(prevState => ({ thresholds: this.sortThresholds(prevState.thresholds) })); + this.setState(prevState => { + const sortThresholds = this.sortThresholds([...prevState.thresholds]); + let index = sortThresholds.length - 1; + sortThresholds.forEach(t => { + t.index = index--; + }); + return { thresholds: sortThresholds }; + }); - this.updateGauge(); + this.onChange(); }; - updateGauge = () => { + onChange = () => { this.props.onChange(this.state.thresholds); }; @@ -129,92 +157,53 @@ export class ThresholdsEditor extends PureComponent { }); }; - renderThresholds() { - const { thresholds } = this.state; - - return thresholds.map((threshold, index) => { - return ( -
-
-
- {threshold.color && ( -
- this.onChangeThresholdColor(threshold, color)} - /> -
- )} -
- this.onChangeThresholdValue(event, threshold)} - value={threshold.value} - onBlur={this.onBlur} - /> -
this.onRemoveThreshold(threshold)} className="threshold-row-remove"> - -
-
-
- ); - }); - } - - renderIndicator() { - const { thresholds } = this.state; - - return thresholds.map((t, i) => { - return ( -
-
this.onAddThreshold(t.index + 1)} style={{ height: '50%', backgroundColor: t.color }} /> -
this.onAddThreshold(t.index)} style={{ height: '50%', backgroundColor: t.color }} /> -
- ); - }); - } - - renderBaseIndicator() { + renderInput = (threshold: Threshold) => { + const value = threshold.index === 0 ? 'Base' : threshold.value; return ( -
-
this.onAddThreshold(0)} - style={{ height: '100%', backgroundColor: BasicGaugeColor.Green }} - /> +
+ +
+ {threshold.color && ( +
+ this.onChangeThresholdColor(threshold, color)} /> +
+ )} +
+
+ this.onChangeThresholdValue(event, threshold)} + value={value} + onBlur={this.onBlur} + readOnly={threshold.index === 0} + /> +
+ {threshold.index > 0 && ( +
this.onRemoveThreshold(threshold)}> + +
+ )}
); - } - - renderBase() { - const baseColor = BasicGaugeColor.Green; - - return ( -
-
-
-
- this.onChangeBaseColor(color)} /> -
-
-
Base
-
-
- ); - } + }; render() { + const { thresholds } = this.state; + return (
-
- {this.renderIndicator()} - {this.renderBaseIndicator()} -
-
- {this.renderThresholds()} - {this.renderBase()} -
+ {thresholds.map((threshold, index) => { + return ( +
+
this.onAddThreshold(threshold.index + 1)}> + +
+
+
{this.renderInput(threshold)}
+
+ ); + })}
); diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss index ff89a6b6ea6..61278321572 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss +++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss @@ -1,46 +1,90 @@ .thresholds { + margin-bottom: 10px; +} + +.thresholds-row { display: flex; + flex-direction: row; + height: 70px; } -.threshold-rows { - margin-left: 5px; +.thresholds-row:first-child > .thresholds-row-color-indicator { + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + overflow: hidden; } -.threshold-row { +.thresholds-row:last-child > .thresholds-row-color-indicator { + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + overflow: hidden; +} + +.thresholds-row-add-button { + align-self: center; + margin-right: 5px; + color: $green; + height: 24px; + width: 24px; + background-color: $green; + border-radius: 50%; display: flex; align-items: center; - margin-top: 3px; - padding: 5px; - - &::before { - font-family: 'FontAwesome'; - content: '\f0d9'; - color: $input-label-border-color; - } + justify-content: center; + cursor: pointer; } -.threshold-row-inner { - border: 1px solid $input-label-border-color; - border-radius: $border-radius; +.thresholds-row-add-button > i { + color: $white; +} + +.thresholds-row-color-indicator { + width: 10px; +} + +.thresholds-row-input { + margin-top: 49px; + margin-left: 2px; +} + +.thresholds-row-input-inner { display: flex; - overflow: hidden; - height: 37px; - - &--base { - width: auto; - } + justify-content: center; + flex-direction: row; } -.threshold-row-color { - width: 36px; - border-right: 1px solid $input-label-border-color; +.thresholds-row-input-inner > *:last-child { + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; +} + +.thresholds-row-input-inner-arrow { + align-self: center; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid $input-label-border-color; +} + +.thresholds-row-input-inner-value > input { + height: $gf-form-input-height; + padding: $input-padding-y $input-padding-x; + width: 150px; + border-top: 1px solid $input-label-border-color; + border-bottom: 1px solid $input-label-border-color; +} + +.thresholds-row-input-inner-color { + width: 42px; display: flex; align-items: center; justify-content: center; background-color: $input-bg; + border: 1px solid $input-label-border-color; } -.threshold-row-color-inner { +.thresholds-row-input-inner-color-colorpicker { border-radius: 10px; overflow: hidden; display: flex; @@ -48,56 +92,14 @@ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); } -.threshold-row-input { - padding: 8px 10px; - width: 150px; -} - -.threshold-row-label { +.thresholds-row-input-inner-remove { + display: flex; + align-items: center; + justify-content: center; + height: $gf-form-input-height; + padding: $input-padding-y $input-padding-x; + width: 42px; background-color: $input-label-bg; - padding: 5px; - display: flex; - align-items: center; -} - -.threshold-row-add-label { - align-items: center; - display: flex; - padding: 5px 8px; -} - -.threshold-row-remove { - display: flex; - align-items: center; - justify-content: center; - height: 37px; - width: 37px; + border: 1px solid $input-label-border-color; cursor: pointer; } - -.threshold-row-add { - border-right: $border-width solid $input-label-border-color; - display: flex; - align-items: center; - justify-content: center; - width: 36px; - background-color: $green; -} - -.threshold-row-label { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.indicator-section { - width: 100%; - height: 50px; - cursor: pointer; -} - -.color-indicators { - width: 15px; - border-bottom-left-radius: $border-radius; - border-bottom-right-radius: $border-radius; - overflow: hidden; -} diff --git a/public/app/plugins/panel/gauge/MappingRow.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx similarity index 51% rename from public/app/plugins/panel/gauge/MappingRow.tsx rename to packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx index b975821f27a..c5704e8bc88 100644 --- a/public/app/plugins/panel/gauge/MappingRow.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx @@ -1,22 +1,22 @@ -import React, { PureComponent } from 'react'; -import { MappingType, RangeMap, Select, ValueMap } from '@grafana/ui'; +import React, { ChangeEvent, PureComponent } from 'react'; -import { Label } from 'app/core/components/Label/Label'; +import { MappingType, ValueMapping } from '../../types'; +import { FormField, FormLabel, Select } from '..'; -interface Props { - mapping: ValueMap | RangeMap; - updateMapping: (mapping) => void; - removeMapping: () => void; +export interface Props { + valueMapping: ValueMapping; + updateValueMapping: (valueMapping: ValueMapping) => void; + removeValueMapping: () => void; } interface State { - from: string; + from?: string; id: number; operator: string; text: string; - to: string; + to?: string; type: MappingType; - value: string; + value?: string; } const mappingOptions = [ @@ -25,36 +25,34 @@ const mappingOptions = [ ]; export default class MappingRow extends PureComponent { - constructor(props) { + constructor(props: Props) { super(props); - this.state = { - ...props.mapping, - }; + this.state = { ...props.valueMapping }; } - onMappingValueChange = event => { + onMappingValueChange = (event: ChangeEvent) => { this.setState({ value: event.target.value }); }; - onMappingFromChange = event => { + onMappingFromChange = (event: ChangeEvent) => { this.setState({ from: event.target.value }); }; - onMappingToChange = event => { + onMappingToChange = (event: ChangeEvent) => { this.setState({ to: event.target.value }); }; - onMappingTextChange = event => { + onMappingTextChange = (event: ChangeEvent) => { this.setState({ text: event.target.value }); }; - onMappingTypeChange = mappingType => { + onMappingTypeChange = (mappingType: MappingType) => { this.setState({ type: mappingType }); }; updateMapping = () => { - this.props.updateMapping({ ...this.state }); + this.props.updateValueMapping({ ...this.state } as ValueMapping); }; renderRow() { @@ -63,30 +61,28 @@ export default class MappingRow extends PureComponent { if (type === MappingType.RangeToText) { return ( <> -
- + + +
+ Text -
-
- - -
-
- -
@@ -96,17 +92,16 @@ export default class MappingRow extends PureComponent { return ( <> -
- - -
+
- + Text { return (
- + Type dashboard.id === homeDashboardId)} getOptionValue={i => i.id} diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx index 86e15923bda..a2c06eef9f5 100644 --- a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx +++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -1,4 +1,4 @@ -import React, { SFC, ReactNode, PureComponent } from 'react'; +import React, { FC, ReactNode, PureComponent } from 'react'; import { Tooltip } from '@grafana/ui'; interface ToggleButtonGroupProps { @@ -29,7 +29,7 @@ interface ToggleButtonProps { tooltip?: string; } -export const ToggleButton: SFC = ({ +export const ToggleButton: FC = ({ children, selected, className = '', diff --git a/public/app/core/components/sidemenu/DropDownChild.tsx b/public/app/core/components/sidemenu/DropDownChild.tsx index 1a577d185e5..41aa794999e 100644 --- a/public/app/core/components/sidemenu/DropDownChild.tsx +++ b/public/app/core/components/sidemenu/DropDownChild.tsx @@ -1,10 +1,10 @@ -import React, { SFC } from 'react'; +import React, { FC } from 'react'; export interface Props { child: any; } -const DropDownChild: SFC = props => { +const DropDownChild: FC = props => { const { child } = props; const listItemClassName = child.divider ? 'divider' : ''; diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.tsx index 7cd7554f82c..4231e992b19 100644 --- a/public/app/core/components/sidemenu/SideMenuDropDown.tsx +++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx @@ -1,11 +1,11 @@ -import React, { SFC } from 'react'; +import React, { FC } from 'react'; import DropDownChild from './DropDownChild'; interface Props { link: any; } -const SideMenuDropDown: SFC = props => { +const SideMenuDropDown: FC = props => { const { link } = props; return (
    diff --git a/public/app/core/components/sidemenu/SignIn.tsx b/public/app/core/components/sidemenu/SignIn.tsx index 17dd913823a..50b3aef2d9b 100644 --- a/public/app/core/components/sidemenu/SignIn.tsx +++ b/public/app/core/components/sidemenu/SignIn.tsx @@ -1,6 +1,6 @@ -import React, { SFC } from 'react'; +import React, { FC } from 'react'; -const SignIn: SFC = () => { +const SignIn: FC = () => { const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`; return (
    diff --git a/public/app/core/components/sidemenu/TopSection.tsx b/public/app/core/components/sidemenu/TopSection.tsx index c6bf5df8242..827b868ea67 100644 --- a/public/app/core/components/sidemenu/TopSection.tsx +++ b/public/app/core/components/sidemenu/TopSection.tsx @@ -1,9 +1,9 @@ -import React, { SFC } from 'react'; +import React, { FC } from 'react'; import _ from 'lodash'; import TopSectionItem from './TopSectionItem'; import config from '../../config'; -const TopSection: SFC = () => { +const TopSection: FC = () => { const navTree = _.cloneDeep(config.bootData.navTree); const mainLinks = _.filter(navTree, item => !item.hideFromMenu); diff --git a/public/app/core/components/sidemenu/TopSectionItem.tsx b/public/app/core/components/sidemenu/TopSectionItem.tsx index 7b3bf96dce8..0aca32c3ba3 100644 --- a/public/app/core/components/sidemenu/TopSectionItem.tsx +++ b/public/app/core/components/sidemenu/TopSectionItem.tsx @@ -1,11 +1,11 @@ -import React, { SFC } from 'react'; +import React, { FC } from 'react'; import SideMenuDropDown from './SideMenuDropDown'; export interface Props { link: any; } -const TopSectionItem: SFC = props => { +const TopSectionItem: FC = props => { const { link } = props; return (
    diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 13d84772ecf..0aa159af84d 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -6,6 +6,8 @@ export interface BuildInfo { commit: string; isEnterprise: boolean; env: string; + latestVersion: string; + hasUpdate: boolean; } export class Settings { diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 6713d8bcd14..fb38cefd435 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -1,5 +1,6 @@ import './directives/dash_class'; import './directives/dropdown_typeahead'; +import './directives/autofill_event_fix'; import './directives/metric_segment'; import './directives/misc'; import './directives/ng_model_on_blur'; diff --git a/public/app/core/directives/autofill_event_fix.ts b/public/app/core/directives/autofill_event_fix.ts new file mode 100644 index 00000000000..51d278fe7c9 --- /dev/null +++ b/public/app/core/directives/autofill_event_fix.ts @@ -0,0 +1,35 @@ +import coreModule from '../core_module'; + +/** @ngInject */ +export function autofillEventFix($compile) { + return { + link: ($scope: any, elem: any) => { + const input = elem[0]; + const dispatchChangeEvent = () => { + const event = new Event('change'); + return input.dispatchEvent(event); + }; + const onAnimationStart = ({ animationName }: AnimationEvent) => { + switch (animationName) { + case 'onAutoFillStart': + return dispatchChangeEvent(); + case 'onAutoFillCancel': + return dispatchChangeEvent(); + } + return null; + }; + + // const onChange = (evt: Event) => console.log(evt); + + input.addEventListener('animationstart', onAnimationStart); + // input.addEventListener('change', onChange); + + $scope.$on('$destroy', () => { + input.removeEventListener('animationstart', onAnimationStart); + // input.removeEventListener('change', onChange); + }); + } + }; +} + +coreModule.directive('autofillEventFix', autofillEventFix); diff --git a/public/app/core/directives/dropdown_typeahead.ts b/public/app/core/directives/dropdown_typeahead.ts index a4bed4fe2b7..dfc3eddbcbb 100644 --- a/public/app/core/directives/dropdown_typeahead.ts +++ b/public/app/core/directives/dropdown_typeahead.ts @@ -141,6 +141,9 @@ export function dropdownTypeahead2($compile) { link: ($scope, elem, attrs) => { const $input = $(inputTemplate); const $button = $(buttonTemplate); + const timeoutId = { + blur: null + }; $input.appendTo(elem); $button.appendTo(elem); @@ -177,6 +180,14 @@ export function dropdownTypeahead2($compile) { [] ); + const closeDropdownMenu = () => { + $input.hide(); + $input.val(''); + $button.show(); + $button.focus(); + elem.removeClass('open'); + }; + $scope.menuItemSelected = (index, subIndex) => { const menuItem = $scope.menuItems[index]; const payload: any = { $item: menuItem }; @@ -184,6 +195,7 @@ export function dropdownTypeahead2($compile) { payload.$subItem = menuItem.submenu[subIndex]; } $scope.dropdownTypeaheadOnSelect(payload); + closeDropdownMenu(); }; $input.attr('data-provide', 'typeahead'); @@ -223,16 +235,15 @@ export function dropdownTypeahead2($compile) { elem.toggleClass('open', $input.val() === ''); }); + elem.mousedown((evt: Event) => { + evt.preventDefault(); + timeoutId.blur = null; + }); + $input.blur(() => { - $input.hide(); - $input.val(''); - $button.show(); - $button.focus(); - // clicking the function dropdown menu won't - // work if you remove class at once - setTimeout(() => { - elem.removeClass('open'); - }, 200); + timeoutId.blur = setTimeout(() => { + closeDropdownMenu(); + }, 1); }); $compile(elem.contents())($scope); diff --git a/public/app/core/selectors/navModel.ts b/public/app/core/selectors/navModel.ts index aa508616962..7d745b58002 100644 --- a/public/app/core/selectors/navModel.ts +++ b/public/app/core/selectors/navModel.ts @@ -41,3 +41,7 @@ export function getNavModel(navIndex: NavIndex, id: string, fallback?: NavModel) return getNotFoundModel(); } + +export const getTitleFromNavModel = (navModel: NavModel) => { + return `${navModel.main.text}${navModel.node.text ? ': ' + navModel.node.text : '' }`; +}; diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index c4134598175..5353fb507cc 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -2,6 +2,7 @@ import config from 'app/core/config'; import _ from 'lodash'; import coreModule from 'app/core/core_module'; import store from 'app/core/store'; +import { ThemeNames, ThemeName } from '@grafana/ui'; export class User { isGrafanaAdmin: any; @@ -59,6 +60,10 @@ export class ContextSrv { this.sidemenu = !this.sidemenu; store.set('grafana.sidemenu', this.sidemenu); } + + getTheme(): ThemeName { + return this.user.lightTheme ? ThemeNames.Light : ThemeNames.Dark; + } } const contextSrv = new ContextSrv(); diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index a3b08516d16..32135eab90a 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -6,26 +6,13 @@ import { clearHistory, hasNonEmptyQuery, } from './explore'; -import { ExploreState } from 'app/types/explore'; +import { ExploreUrlState } from 'app/types/explore'; import store from 'app/core/store'; -const DEFAULT_EXPLORE_STATE: ExploreState = { +const DEFAULT_EXPLORE_STATE: ExploreUrlState = { datasource: null, - datasourceError: null, - datasourceLoading: null, - datasourceMissing: false, - exploreDatasources: [], - graphInterval: 1000, - history: [], - initialQueries: [], - queryTransactions: [], + queries: [], range: DEFAULT_RANGE, - showingGraph: true, - showingLogs: true, - showingTable: true, - supportsGraph: null, - supportsLogs: null, - supportsTable: null, }; describe('state functions', () => { @@ -68,21 +55,19 @@ describe('state functions', () => { it('returns url parameter value for a state object', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now-5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; expect(serializeStateToUrlParam(state)).toBe( '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' + @@ -93,21 +78,19 @@ describe('state functions', () => { it('returns url parameter value for a state object', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now-5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; expect(serializeStateToUrlParam(state, true)).toBe( '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]' @@ -119,35 +102,24 @@ describe('state functions', () => { it('can parse the serialized state into the original state', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now - 5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; const serialized = serializeStateToUrlParam(state); const parsed = parseUrlState(serialized); - // Account for datasource vs datasourceName - const { datasource, queries, ...rest } = parsed; - const resultState = { - ...rest, - datasource: DEFAULT_EXPLORE_STATE.datasource, - initialDatasource: datasource, - initialQueries: queries, - }; - - expect(state).toMatchObject(resultState); + expect(state).toMatchObject(parsed); }); }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index f3273ffa16d..45b70672bc6 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,16 +1,26 @@ +// Libraries import _ from 'lodash'; -import { colors } from '@grafana/ui'; +// Services & Utils +import * as dateMath from 'app/core/utils/datemath'; import { renderUrl } from 'app/core/utils/url'; import kbn from 'app/core/utils/kbn'; import store from 'app/core/store'; import { parse as parseDate } from 'app/core/utils/datemath'; - -import TimeSeries from 'app/core/time_series2'; +import { colors } from '@grafana/ui'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; -import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore'; -import { DataQuery, DataSourceApi } from 'app/types/series'; -import { RawTimeRange, IntervalValues } from '@grafana/ui'; + +// Types +import { RawTimeRange, IntervalValues, DataQuery } from '@grafana/ui/src/types'; +import TimeSeries from 'app/core/time_series2'; +import { + ExploreUrlState, + HistoryItem, + QueryTransaction, + ResultType, + QueryIntervals, + QueryOptions, +} from 'app/types/explore'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -19,6 +29,8 @@ export const DEFAULT_RANGE = { const MAX_HISTORY_ITEMS = 100; +export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource'; + /** * Returns an Explore-URL that contains a panel's queries and the dashboard time range. * @@ -77,7 +89,63 @@ export async function getExploreUrl( return url; } -const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; +export function buildQueryTransaction( + query: DataQuery, + rowIndex: number, + resultType: ResultType, + queryOptions: QueryOptions, + range: RawTimeRange, + queryIntervals: QueryIntervals, + scanning: boolean +): QueryTransaction { + const { interval, intervalMs } = queryIntervals; + + const configuredQueries = [ + { + ...query, + ...queryOptions, + }, + ]; + + // Clone range for query request + // const queryRange: RawTimeRange = { ...range }; + // const { from, to, raw } = this.timeSrv.timeRange(); + // Most datasource is using `panelId + query.refId` for cancellation logic. + // Using `format` here because it relates to the view panel that the request is for. + // However, some datasources don't use `panelId + query.refId`, but only `panelId`. + // Therefore panel id has to be unique. + const panelId = `${queryOptions.format}-${query.key}`; + + const options = { + interval, + intervalMs, + panelId, + targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key. + range: { + from: dateMath.parse(range.from, false), + to: dateMath.parse(range.to, true), + raw: range, + }, + rangeRaw: range, + scopedVars: { + __interval: { text: interval, value: interval }, + __interval_ms: { text: intervalMs, value: intervalMs }, + }, + }; + + return { + options, + query, + resultType, + rowIndex, + scanning, + id: generateKey(), // reusing for unique ID + done: false, + latency: 0, + }; +} + +export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; export function parseUrlState(initial: string | undefined): ExploreUrlState { if (initial) { @@ -103,12 +171,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { return { datasource: null, queries: [], range: DEFAULT_RANGE }; } -export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string { - const urlState: ExploreUrlState = { - datasource: state.initialDatasource, - queries: state.initialQueries.map(clearQueryKeys), - range: state.range, - }; +export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string { if (compact) { return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]); } @@ -123,7 +186,7 @@ export function generateRefId(index = 0): string { return `${index + 1}`; } -export function generateQueryKeys(index = 0): { refId: string; key: string } { +export function generateEmptyQuery(index = 0): { refId: string; key: string } { return { refId: generateRefId(index), key: generateKey(index) }; } @@ -132,20 +195,23 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } { */ export function ensureQueries(queries?: DataQuery[]): DataQuery[] { if (queries && typeof queries === 'object' && queries.length > 0) { - return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) })); + return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) })); } - return [{ ...generateQueryKeys() }]; + return [{ ...generateEmptyQuery() }]; } /** * A target is non-empty when it has keys (with non-empty values) other than refId and key. */ -export function hasNonEmptyQuery(queries: DataQuery[]): boolean { - return queries.some( - query => - Object.keys(query) - .map(k => query[k]) - .filter(v => v).length > 2 +export function hasNonEmptyQuery(queries: TQuery[]): boolean { + return ( + queries && + queries.some( + query => + Object.keys(query) + .map(k => query[k]) + .filter(v => v).length > 2 + ) ); } @@ -180,8 +246,8 @@ export function calculateResultsFromQueryTransactions( }; } -export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues { - if (!datasource || !resolution) { +export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues { + if (!resolution) { return { interval: '1s', intervalMs: 1000 }; } @@ -190,7 +256,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res to: parseDate(range.to, true), }; - return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); + return kbn.calculateInterval(absoluteRange, resolution, lowLimit); } export function makeTimeSeriesList(dataList) { @@ -214,7 +280,11 @@ export function makeTimeSeriesList(dataList) { /** * Update the query history. Side-effect: store history in local storage */ -export function updateHistory(history: HistoryItem[], datasourceId: string, queries: DataQuery[]): HistoryItem[] { +export function updateHistory( + history: Array>, + datasourceId: string, + queries: T[] +): Array> { const ts = Date.now(); queries.forEach(query => { history = [{ query, ts }, ...history]; diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx index 54200234ddc..cd640b5a357 100644 --- a/public/app/features/api-keys/ApiKeysPage.test.tsx +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -6,7 +6,14 @@ import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock'; const setup = (propOverrides?: object) => { const props: Props = { - navModel: {} as NavModel, + navModel: { + main: { + text: 'Configuration' + }, + node: { + text: 'Api Keys' + } + } as NavModel, apiKeys: [] as ApiKey[], searchQuery: '', hasFetched: false, diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index e14873fa9f6..41b9b0c8a55 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -6,8 +6,7 @@ import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { getApiKeys, getApiKeysCount } from './state/selectors'; import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import Page from 'app/core/components/Page/Page'; import SlideDown from 'app/core/components/Animations/SlideDown'; import ApiKeysAddedModal from './ApiKeysAddedModal'; import config from 'app/core/config'; @@ -240,18 +239,17 @@ export class ApiKeysPage extends PureComponent { const { hasFetched, navModel, apiKeysCount } = this.props; return ( -
    - - {hasFetched ? ( - apiKeysCount > 0 ? ( - this.renderApiKeyList() - ) : ( - this.renderEmptyList() - ) - ) : ( - - )} -
    + + + {hasFetched && ( + apiKeysCount > 0 ? ( + this.renderApiKeyList() + ) : ( + this.renderEmptyList() + ) + )} + + ); } } diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap index 7ede9618250..f40894426ae 100644 --- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -1,132 +1,152 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should render API keys table if there are any keys 1`] = ` -
    - + - -
    + `; exports[`Render should render CTA if there are no API keys 1`] = ` -
    - -
    + - - -
    + - -
    - Add API Key -
    -
    -
    + + +
    + Add API Key +
    +
    - - Key name - - -
    -
    - - Role - - - +
    +
    + + Role + + + - -
    -
    -
    +
    - Add - + +
    -
    - -
    - -
    -
    + +
    + +
+ + `; diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx index d71a274ab10..d4f6859f1b6 100644 --- a/public/app/features/dashboard/dashgrid/DataPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx @@ -12,8 +12,7 @@ import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource import kbn from 'app/core/utils/kbn'; // Types -import { DataQueryOptions, DataQueryResponse } from 'app/types'; -import { TimeRange, TimeSeries, LoadingState } from '@grafana/ui'; +import { TimeRange, TimeSeries, LoadingState, DataQueryResponse, DataQueryOptions } from '@grafana/ui/src/types'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 46534cac065..6b4ef48c32e 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -20,6 +20,7 @@ import { PanelPlugin } from 'app/types'; import { TimeRange } from '@grafana/ui'; import variables from 'sass/_variables.scss'; +import templateSrv from 'app/features/templating/template_srv'; export interface Props { panel: PanelModel; @@ -78,6 +79,10 @@ export class PanelChrome extends PureComponent { }); }; + onInterpolate = (value: string, format?: string) => { + return templateSrv.replace(value, this.props.panel.scopedVars, format); + }; + get isVisible() { return !this.props.dashboard.otherPanelInFullscreen(this.props.panel); } @@ -124,9 +129,10 @@ export class PanelChrome extends PureComponent { timeSeries={timeSeries} timeRange={timeRange} options={panel.getOptions(plugin.exports.PanelDefaults)} - width={width - 2 * variables.panelHorizontalPadding } + width={width - 2 * variables.panelHorizontalPadding} height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding} renderCounter={renderCounter} + onInterpolate={this.onInterpolate} />
); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 8b7afd7d09e..b5cd9258c08 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import PanelHeaderCorner from './PanelHeaderCorner'; import { PanelHeaderMenu } from './PanelHeaderMenu'; +import templateSrv from 'app/features/templating/template_srv'; import { DashboardModel } from 'app/features/dashboard/dashboard_model'; import { PanelModel } from 'app/features/dashboard/panel_model'; @@ -45,7 +46,9 @@ export class PanelHeader extends Component { const isFullscreen = false; const isLoading = false; const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen }); - const { panel, dashboard, timeInfo } = this.props; + const { panel, dashboard, timeInfo, scopedVars } = this.props; + const title = templateSrv.replaceWithText(panel.title, scopedVars); + return ( <> {
- {panel.title} + {title} {this.state.panelMenuOpen && ( diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx index d42b48fe1d6..66a942f0afc 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx @@ -1,11 +1,11 @@ -import React, { SFC } from 'react'; +import React, { FC } from 'react'; import { PanelMenuItem } from '@grafana/ui'; interface Props { children: any; } -export const PanelHeaderMenuItem: SFC = props => { +export const PanelHeaderMenuItem: FC = props => { const isSubMenu = props.type === 'submenu'; const isDivider = props.type === 'divider'; return isDivider ? ( diff --git a/public/app/features/dashboard/dashgrid/PanelResizer.tsx b/public/app/features/dashboard/dashgrid/PanelResizer.tsx index 2a4bf8379a6..ca8abd0d1e3 100644 --- a/public/app/features/dashboard/dashgrid/PanelResizer.tsx +++ b/public/app/features/dashboard/dashgrid/PanelResizer.tsx @@ -15,7 +15,7 @@ interface State { } export class PanelResizer extends PureComponent { - initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.4); + initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.3); prevEditorHeight: number; throttledChangeHeight: (height: number) => void; throttledResizeDone: () => void; diff --git a/public/app/features/dashboard/panel_editor/DataSourceOption.tsx b/public/app/features/dashboard/panel_editor/DataSourceOption.tsx index 9a3ce527510..e4bbcfffe1d 100644 --- a/public/app/features/dashboard/panel_editor/DataSourceOption.tsx +++ b/public/app/features/dashboard/panel_editor/DataSourceOption.tsx @@ -1,4 +1,4 @@ -import React, { SFC } from 'react'; +import React, { FC } from 'react'; import { Tooltip } from '@grafana/ui'; interface Props { @@ -10,7 +10,7 @@ interface Props { tooltipInfo?: any; } -export const DataSourceOptions: SFC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => { +export const DataSourceOptions: FC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => { const dsOption = (
diff --git a/public/app/features/dashboard/panel_editor/EditorTabBody.tsx b/public/app/features/dashboard/panel_editor/EditorTabBody.tsx index dbea7ed59bc..0413cae8a7b 100644 --- a/public/app/features/dashboard/panel_editor/EditorTabBody.tsx +++ b/public/app/features/dashboard/panel_editor/EditorTabBody.tsx @@ -10,6 +10,8 @@ interface Props { heading: string; renderToolbar?: () => JSX.Element; toolbarItems?: EditorToolbarView[]; + scrollTop?: number; + setScrollTop?: (value: React.MouseEvent) => void; } export interface EditorToolbarView { @@ -103,23 +105,20 @@ export class EditorTabBody extends PureComponent { } render() { - const { children, renderToolbar, heading, toolbarItems } = this.props; + const { children, renderToolbar, heading, toolbarItems, scrollTop, setScrollTop } = this.props; const { openView, fadeIn, isOpen } = this.state; return ( <>
-
{heading}
- {renderToolbar && renderToolbar()} - {toolbarItems.length > 0 && ( - <> -
- {toolbarItems.map(item => this.renderButton(item))} - - )} +
+
{heading}
+ {renderToolbar && renderToolbar()} +
+ {toolbarItems.map(item => this.renderButton(item))}
- +
{openView && this.renderOpenView(openView)} diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx index 47c4f358136..ca06098debd 100644 --- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx +++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx @@ -3,24 +3,22 @@ import React, { PureComponent } from 'react'; import _ from 'lodash'; // Components -import 'app/features/panel/metrics_tab'; import { EditorTabBody, EditorToolbarView } from './EditorTabBody'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { QueryInspector } from './QueryInspector'; import { QueryOptions } from './QueryOptions'; -import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab'; import { PanelOptionsGroup } from '@grafana/ui'; +import { QueryEditorRow } from './QueryEditorRow'; // Services import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; -import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; import config from 'app/core/config'; // Types import { PanelModel } from '../panel_model'; import { DashboardModel } from '../dashboard_model'; -import { DataQuery, DataSourceSelectItem } from 'app/types'; +import { DataQuery, DataSourceSelectItem } from '@grafana/ui/src/types'; import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; interface Props { @@ -34,66 +32,27 @@ interface State { isLoadingHelp: boolean; isPickerOpen: boolean; isAddingMixed: boolean; + scrollTop: number; } export class QueriesTab extends PureComponent { - element: HTMLElement; - component: AngularComponent; datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources(); backendSrv: BackendSrv = getBackendSrv(); - constructor(props) { - super(props); - - this.state = { - isLoadingHelp: false, - currentDS: this.findCurrentDataSource(), - helpContent: null, - isPickerOpen: false, - isAddingMixed: false, - }; - } + state: State = { + isLoadingHelp: false, + currentDS: this.findCurrentDataSource(), + helpContent: null, + isPickerOpen: false, + isAddingMixed: false, + scrollTop: 0, + }; findCurrentDataSource(): DataSourceSelectItem { const { panel } = this.props; return this.datasources.find(datasource => datasource.value === panel.datasource) || this.datasources[0]; } - getAngularQueryComponentScope(): AngularQueryComponentScope { - const { panel, dashboard } = this.props; - - return { - panel: panel, - dashboard: dashboard, - refresh: () => panel.refresh(), - render: () => panel.render, - addQuery: this.onAddQuery, - moveQuery: this.onMoveQuery, - removeQuery: this.onRemoveQuery, - events: panel.events, - }; - } - - componentDidMount() { - if (!this.element) { - return; - } - - const loader = getAngularLoader(); - const template = ''; - const scopeProps = { - ctrl: this.getAngularQueryComponentScope(), - }; - - this.component = loader.load(this.element, scopeProps, template); - } - - componentWillUnmount() { - if (this.component) { - this.component.destroy(); - } - } - onChangeDataSource = datasource => { const { panel } = this.props; const { currentDS } = this.state; @@ -137,7 +96,7 @@ export class QueriesTab extends PureComponent { onAddQuery = (query?: Partial) => { this.props.panel.addQuery(query); - this.forceUpdate(); + this.setState({ scrollTop: this.state.scrollTop + 100000 }); }; onAddQueryClick = () => { @@ -146,9 +105,7 @@ export class QueriesTab extends PureComponent { return; } - this.props.panel.addQuery(); - this.component.digest(); - this.forceUpdate(); + this.onAddQuery(); }; onRemoveQuery = (query: DataQuery) => { @@ -171,9 +128,20 @@ export class QueriesTab extends PureComponent { }; renderToolbar = () => { - const { currentDS } = this.state; + const { currentDS, isAddingMixed } = this.state; - return ; + return ( + <> + +
+ {!isAddingMixed && ( + + )} + {isAddingMixed && this.renderMixedPicker()} + + ); }; renderMixedPicker = () => { @@ -190,17 +158,21 @@ export class QueriesTab extends PureComponent { onAddMixedQuery = datasource => { this.onAddQuery({ datasource: datasource.name }); - this.component.digest(); - this.setState({ isAddingMixed: false }); + this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 }); }; onMixedPickerBlur = () => { this.setState({ isAddingMixed: false }); }; + setScrollTop = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop }); + }; + render() { const { panel } = this.props; - const { currentDS, isAddingMixed } = this.state; + const { currentDS, scrollTop } = this.state; const queryInspector: EditorToolbarView = { title: 'Query Inspector', @@ -214,32 +186,28 @@ export class QueriesTab extends PureComponent { }; return ( - + <> - -
-
(this.element = element)} /> - -
-
- -
-
- {!isAddingMixed && ( - - )} - {isAddingMixed && this.renderMixedPicker()} -
-
-
- +
+ {panel.targets.map((query, index) => ( + + ))} +
diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx new file mode 100644 index 00000000000..1239561e695 --- /dev/null +++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx @@ -0,0 +1,254 @@ +// Libraries +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import _ from 'lodash'; + +// Utils & Services +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; +import { Emitter } from 'app/core/utils/emitter'; + +// Types +import { PanelModel } from '../panel_model'; +import { DataQuery, DataSourceApi } from '@grafana/ui'; + +interface Props { + panel: PanelModel; + query: DataQuery; + onAddQuery: (query?: DataQuery) => void; + onRemoveQuery: (query: DataQuery) => void; + onMoveQuery: (query: DataQuery, direction: number) => void; + datasourceName: string | null; + inMixedMode: boolean; +} + +interface State { + datasource: DataSourceApi | null; + isCollapsed: boolean; + angularScope: AngularQueryComponentScope | null; +} + +export class QueryEditorRow extends PureComponent { + element: HTMLElement | null = null; + angularQueryEditor: AngularComponent | null = null; + + state: State = { + datasource: null, + isCollapsed: false, + angularScope: null, + }; + + componentDidMount() { + this.loadDatasource(); + } + + getAngularQueryComponentScope(): AngularQueryComponentScope { + const { panel, query } = this.props; + const { datasource } = this.state; + + return { + datasource: datasource, + target: query, + panel: panel, + refresh: () => panel.refresh(), + render: () => panel.render(), + events: panel.events, + }; + } + + async loadDatasource() { + const { query, panel } = this.props; + const dataSourceSrv = getDatasourceSrv(); + const datasource = await dataSourceSrv.get(query.datasource || panel.datasource); + + this.setState({ datasource }); + } + + componentDidUpdate() { + const { datasource } = this.state; + + // check if we need to load another datasource + if (datasource && datasource.name !== this.props.datasourceName) { + if (this.angularQueryEditor) { + this.angularQueryEditor.destroy(); + this.angularQueryEditor = null; + } + this.loadDatasource(); + return; + } + + if (!this.element || this.angularQueryEditor) { + return; + } + + const loader = getAngularLoader(); + const template = ''; + const scopeProps = { ctrl: this.getAngularQueryComponentScope() }; + + this.angularQueryEditor = loader.load(this.element, scopeProps, template); + + // give angular time to compile + setTimeout(() => { + this.setState({ angularScope: scopeProps.ctrl }); + }, 10); + } + + componentWillUnmount() { + if (this.angularQueryEditor) { + this.angularQueryEditor.destroy(); + } + } + + onToggleCollapse = () => { + this.setState({ isCollapsed: !this.state.isCollapsed }); + }; + + onQueryChange = (query: DataQuery) => { + Object.assign(this.props.query, query); + this.onExecuteQuery(); + }; + + onExecuteQuery = () => { + this.props.panel.refresh(); + }; + + renderPluginEditor() { + const { query } = this.props; + const { datasource } = this.state; + + if (datasource.pluginExports.QueryCtrl) { + return
(this.element = element)} />; + } + + if (datasource.pluginExports.QueryEditor) { + const QueryEditor = datasource.pluginExports.QueryEditor; + return ( + + ); + } + + return
Data source plugin does not export any Query Editor component
; + } + + onToggleEditMode = () => { + const { angularScope } = this.state; + + if (angularScope && angularScope.toggleEditorMode) { + angularScope.toggleEditorMode(); + this.angularQueryEditor.digest(); + } + + if (this.state.isCollapsed) { + this.setState({ isCollapsed: false }); + } + }; + + get hasTextEditMode() { + const { angularScope } = this.state; + return angularScope && angularScope.toggleEditorMode; + } + + onRemoveQuery = () => { + this.props.onRemoveQuery(this.props.query); + }; + + onCopyQuery = () => { + const copy = _.cloneDeep(this.props.query); + this.props.onAddQuery(copy); + }; + + onDisableQuery = () => { + this.props.query.hide = !this.props.query.hide; + this.forceUpdate(); + }; + + renderCollapsedText(): string | null { + const { angularScope } = this.state; + + if (angularScope && angularScope.getCollapsedText) { + return angularScope.getCollapsedText(); + } + + return null; + } + + render() { + const { query, datasourceName, inMixedMode } = this.props; + const { datasource, isCollapsed } = this.state; + const isDisabled = query.hide; + + const bodyClasses = classNames('query-editor-row__body gf-form-query', { + 'query-editor-row__body--collapsed': isCollapsed, + }); + + const rowClasses = classNames('query-editor-row', { + 'query-editor-row--disabled': isDisabled, + 'gf-form-disabled': isDisabled, + }); + + if (!datasource) { + return null; + } + + return ( +
+
+
+ {isCollapsed && } + {!isCollapsed && } + {query.refId} + {inMixedMode && ({datasourceName})} + {isDisabled && Disabled} +
+
+ {isCollapsed &&
{this.renderCollapsedText()}
} +
+
+ {this.hasTextEditMode && ( + + )} + + + + + +
+
+
{this.renderPluginEditor()}
+
+ ); + } +} + +export interface AngularQueryComponentScope { + target: DataQuery; + panel: PanelModel; + events: Emitter; + refresh: () => void; + render: () => void; + datasource: DataSourceApi; + toggleEditorMode?: () => void; + getCollapsedText?: () => string; +} diff --git a/public/app/features/dashboard/panel_editor/QueryInspector.tsx b/public/app/features/dashboard/panel_editor/QueryInspector.tsx index 8e490f6b622..25c3c68e21e 100644 --- a/public/app/features/dashboard/panel_editor/QueryInspector.tsx +++ b/public/app/features/dashboard/panel_editor/QueryInspector.tsx @@ -177,7 +177,6 @@ export class QueryInspector extends PureComponent { render() { const { response, isLoading } = this.state.dsQuery; - const { isMocking } = this.state; const openNodes = this.getNrOfOpenNodes(); if (isLoading) { @@ -199,20 +198,7 @@ export class QueryInspector extends PureComponent {
- {!isMocking && } - {isMocking && ( -
-
-